@gitterm/sdk 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,228 +1,161 @@
1
1
  # @gitterm/sdk
2
2
 
3
- TypeScript SDK for the [GitTerm](https://gitterm.dev) API. Used by the `gitterm` CLI,
4
- the OpenCode plugin, and any integration that needs to manage GitTerm workspaces with
5
- a user API token.
3
+ TypeScript SDK for the [GitTerm](https://gitterm.dev) API: create sandboxed workspaces for a
4
+ repository, run an agent in them, and relay the questions it asks back to a human. Used by the
5
+ `gitterm` CLI, the OpenCode plugin, and integrations such as Slack bots.
6
6
 
7
- ## Direct provider mode
7
+ ## Install
8
8
 
9
- Direct mode runs an agent using your cloud-provider account without a Gitterm server. It intentionally omits managed billing, proxying, policy, durable run history, and automatic cleanup; your application owns workspace state and lifecycle.
9
+ ```sh
10
+ bun add @gitterm/sdk
11
+ # or
12
+ npm install @gitterm/sdk
13
+ ```
10
14
 
11
- All built-in compute providers use the same provisioning plan and workspace/run API:
15
+ Requires Node 22.12+ or Bun. Create an API token in the dashboard under
16
+ **Settings → Account → API tokens**, or with `gitterm login`.
12
17
 
13
- | Provider | Direct prerequisite | Persistent pause | Keep-alive |
14
- | -------- | -------------------------------------------------------------- | ---------------- | ---------- |
15
- | E2B | OpenCode-compatible template | Yes | Yes |
16
- | Daytona | Public Gitterm OpenCode server image by default | Yes | Yes |
17
- | Vercel | Vercel Sandbox project | Yes | Yes |
18
- | Ascii | Box API key | Yes | Yes |
19
- | exe.dev | Lifecycle token, or an existing VM with `ls,ssh,share,ssh-key` | Yes | No |
20
- | Railway | Project/environment and public service domains | With a volume | No |
21
-
22
- AWS remains available through `createGittermClient()` and the Gitterm control plane; it is intentionally not exposed in direct mode.
18
+ ## Quick start
23
19
 
24
- Cloudflare remains available through the Gitterm control plane. Direct Cloudflare support is deferred until the OpenCode v2 Workerd runtime is stable.
20
+ Create a workspace, run a prompt, and get the final result:
25
21
 
26
22
  ```ts
27
- import { createDirectGittermClient } from "@gitterm/sdk/direct";
23
+ import { createGittermClient } from "@gitterm/sdk";
28
24
 
29
- const direct = createDirectGittermClient({
30
- provider: {
31
- type: "e2b",
32
- apiKey: process.env.E2B_API_KEY!,
33
- size: "standard",
34
- },
35
- });
25
+ const client = createGittermClient({ token: process.env.GITTERM_API_TOKEN });
36
26
 
37
- let workspace = await direct.workspaces.create({
38
- repo: "https://github.com/acme/project",
39
- lifecycle: "ephemeral",
40
- modelCredentials: [{ providerName: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY! }],
27
+ const { workspace } = await client.workspaces.create({
28
+ repo: "https://github.com/acme/product",
29
+ repositoryCredentials: { token: process.env.GITHUB_TOKEN! },
30
+ autoTerminateAfterMs: 2 * 60 * 60 * 1000,
41
31
  });
42
32
 
43
33
  try {
44
- const run = await direct.runs.create({ workspace, prompt: "Review the open pull request" });
45
- const completed = await direct.runs.wait(run, workspace);
46
- console.log(completed.finalText);
34
+ const run = await client.runs.create({
35
+ workspace,
36
+ idempotencyKey: "review-pr-42",
37
+ prompt: "Review PR #42 and fix the failing tests.",
38
+ });
39
+ const result = await client.runs.result(run);
40
+ console.log(result.finalText);
47
41
  } finally {
48
- workspace = await direct.workspaces.terminate(workspace);
42
+ await client.workspaces.terminate(workspace);
49
43
  }
50
44
  ```
51
45
 
52
- `DirectWorkspace` is JSON-serializable. Persist it together with the returned `sessionId` to resume provider lifecycle and OpenCode conversation context after an application restart. The serialized workspace contains the OpenCode password and may contain provider routing tokens, so encrypt it as credential material. Custom providers can implement `DirectProviderAdapter`; use `client.provider.capabilities` rather than hard-coding lifecycle assumptions.
53
-
54
- Every adapter receives the same normalized plan: repository/ref and optional Git credentials, agent files, model credentials, environment, setup commands, serve command, and port. Provider-specific configuration only describes how to allocate and expose compute.
55
-
56
- Direct setup has explicit phases. `beforeAgent` blocks workspace creation, while
57
- `afterAgent` runs in the background and can be observed with `setupStatus()` or
58
- `waitForSetup()`:
59
-
60
- ```ts
61
- const workspace = await direct.workspaces.create({
62
- repo: "https://github.com/acme/project",
63
- setup: {
64
- beforeAgent: ["npm install"],
65
- afterAgent: ["npm run generate"],
66
- },
67
- secretFiles: [
68
- {
69
- path: "~/.config/gcloud/service-account.json",
70
- content: process.env.GCP_SERVICE_ACCOUNT_JSON!,
71
- mode: 0o600,
72
- },
73
- ],
74
- });
75
-
76
- await direct.workspaces.waitForSetup(workspace);
77
- ```
78
-
79
- To attach to an existing exe.dev VM without giving Gitterm ownership of that VM, pass
80
- `exedev: { existingVmName: "acme-agent-machine" }` to `workspaces.create()`. Terminating
81
- that workspace stops only its tracked agent process and does not remove the VM.
46
+ `result()` returns only on successful completion. If the agent asks something and no handler is
47
+ registered, it throws `AgentRunError` with code `INPUT_REQUIRED` and the current `error.run`.
48
+ Register `onPermission` / `onQuestion` handlers for interactive runs, or use the event relay below.
49
+ The quick start's `finally` deliberately terminates the workspace on any error; omit that cleanup
50
+ if your application needs to keep a blocked run available for a later answer.
82
51
 
83
- Trusted integration context can be appended to the generated global `AGENTS.md` without changing the model system prompt:
52
+ `askHuman` is your side of an event relay: show the request, wait for an answer, and return an
53
+ `AgentRunReply`. This terminal example gives up after ten minutes:
84
54
 
85
55
  ```ts
86
- await direct.workspaces.create({
87
- repo: "https://github.com/acme/project",
88
- additionalAgentInstructions:
89
- "You are running as a Slack bot. Keep responses concise and suitable for a thread.",
90
- });
91
- ```
92
-
93
- ### Provider authentication
94
-
95
- Direct workspaces can start OpenCode provider authentication without shell access. Discover the provider's methods and select a headless or device-code OAuth method when OpenCode is running remotely:
96
-
97
- ```ts
98
- const openai = await direct.auth.get(workspace, "openai");
99
- const method = openai.methods.find(
100
- (item) => item.type === "oauth" && item.id === "chatgpt-headless",
101
- );
102
- if (!method || method.type !== "oauth") throw new Error("OpenAI device OAuth is unavailable");
103
-
104
- const attempt = await direct.auth.connectOAuth({
105
- workspace,
106
- integrationId: "openai",
107
- methodId: method.id,
108
- label: "Slack bot",
109
- });
110
-
111
- // Present these through your application UI.
112
- console.log(attempt.url, attempt.instructions);
113
-
114
- if (attempt.mode === "auto") {
115
- await direct.auth.wait(attempt, workspace);
116
- } else {
117
- await direct.auth.complete(attempt, workspace, await getCodeFromUser());
56
+ import { createInterface } from "node:readline/promises";
57
+ import type { AgentRunInputRequest, AgentRunReply } from "@gitterm/sdk";
58
+
59
+ async function prompt(text: string, signal: AbortSignal): Promise<string | null> {
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
61
+ try {
62
+ return (await rl.question(text, { signal })).trim();
63
+ } catch {
64
+ return null; // the deadline passed
65
+ } finally {
66
+ rl.close();
67
+ }
118
68
  }
119
- ```
120
-
121
- OAuth started this way is stored and refreshed by OpenCode inside the workspace. Reusing a persistent workspace avoids repeated authentication; terminating an ephemeral workspace also destroys its credential store. OpenCode does not export OAuth tokens from this flow.
122
-
123
- Applications that own OAuth separately can keep the token bundle in encrypted storage and inject it into every new workspace instead:
124
-
125
- ```ts
126
- const credential = await credentialStore.get(slackInstallationId);
127
- const workspace = await direct.workspaces.create({
128
- lifecycle: "ephemeral",
129
- modelCredentials: [
130
- {
131
- type: "oauth",
132
- providerName: "openai",
133
- refreshToken: credential.refreshToken,
134
- accessToken: credential.accessToken,
135
- expiresAt: credential.expiresAt,
136
- accountId: credential.accountId,
137
- },
138
- ],
139
- });
140
-
141
- // Credentials can also be added or rotated on an existing runtime.
142
- await direct.auth.setCredential(workspace, {
143
- type: "oauth",
144
- providerName: "openai",
145
- refreshToken: credential.refreshToken,
146
- accessToken: credential.accessToken,
147
- expiresAt: credential.expiresAt,
148
- });
149
- ```
150
-
151
- In this mode the application owns encryption, tenant scoping, refresh, and persistence. OpenCode may refresh its workspace-local copy; the direct SDK does not copy rotated tokens back into application storage. Use the Gitterm control plane when those credential-management responsibilities should be managed centrally.
152
69
 
153
- ## Install
154
-
155
- ```sh
156
- bun add @gitterm/sdk
157
- # or
158
- npm install @gitterm/sdk
70
+ async function askHuman(request: AgentRunInputRequest): Promise<AgentRunReply> {
71
+ const deadline = AbortSignal.timeout(10 * 60_000);
72
+ if (request.kind === "permission") {
73
+ // request.title, e.g. "bash: rm -rf dist"
74
+ const answer = await prompt(`Allow ${request.title}? [yes/always/no] `, deadline);
75
+ if (answer === null) return { type: "permission", response: "reject" };
76
+ return {
77
+ type: "permission",
78
+ response: answer === "always" ? "always" : answer === "yes" ? "once" : "reject",
79
+ };
80
+ }
81
+ const answers: Record<string, string[]> = {};
82
+ for (const question of request.questions) {
83
+ const labels = question.options.map((option) => option.label).join(" | ");
84
+ const answer = await prompt(`${question.header}: ${question.question} [${labels}] `, deadline);
85
+ if (answer === null) return { type: "question", reject: true }; // nobody answered in time
86
+ answers[question.key] = question.multiple ? answer.split(",").map((s) => s.trim()) : [answer];
87
+ }
88
+ return { type: "question", answers };
89
+ }
159
90
  ```
160
91
 
161
- ## Switching servers (hosted vs self-hosted)
92
+ Rejecting a permission or a question ends the agent's turn; the run then finishes as
93
+ `completed` or `failed` with what it managed to do. The rest of this document explains each
94
+ step in depth. Direct provider mode, which runs the same API against your own cloud account
95
+ without a GitTerm server, is described at the [end](#direct-provider-mode).
162
96
 
163
- The default API is the hosted service at `https://api.gitterm.dev`. Self-hosted
164
- instances use the same SDK — pass your instance’s base URL as `serverUrl`.
97
+ ## Configuration
165
98
 
166
- | Deployment | Example `serverUrl` |
167
- | ----------- | ----------------------------- |
168
- | Hosted | `https://api.gitterm.dev` |
169
- | Self-hosted | `https://gitterm.example.com` |
170
- | Local dev | `http://localhost:3000` |
171
-
172
- ### Explicit client (recommended for apps)
99
+ `createGittermClient()` resolves its server and token in this order: constructor options →
100
+ `GITTERM_SERVER_URL` / `GITTERM_API_TOKEN` → the CLI config at `~/.config/gitterm/cli.json`
101
+ written by `gitterm login`. The default server is the hosted API at `https://api.gitterm.dev`;
102
+ self-hosted instances use the same SDK with their own `serverUrl`:
173
103
 
174
104
  ```ts
175
- import { createGittermClient } from "@gitterm/sdk";
176
-
177
- // Hosted
178
- const hosted = createGittermClient({
179
- serverUrl: "https://api.gitterm.dev",
180
- token: process.env.GITTERM_API_TOKEN,
181
- });
182
-
183
- // Self-hosted / local
105
+ const hosted = createGittermClient({ token: process.env.GITTERM_API_TOKEN });
184
106
  const selfHosted = createGittermClient({
185
107
  serverUrl: "https://gitterm.example.com", // or http://localhost:3000
186
108
  token: process.env.GITTERM_API_TOKEN,
187
109
  });
110
+ const fromCli = createGittermClient(); // env vars, then the CLI's saved login
188
111
  ```
189
112
 
190
- ### Environment variables
113
+ Tokens are the same `gt_...` shape on hosted and self-hosted. `client.auth.status()` and
114
+ `client.serverUrl` show which account and server you hit.
191
115
 
192
- ```bash
193
- export GITTERM_SERVER_URL=https://gitterm.example.com
194
- export GITTERM_API_TOKEN=gt_...
195
- ```
116
+ ## API
196
117
 
197
118
  ```ts
198
- // Picks up GITTERM_SERVER_URL + GITTERM_API_TOKEN
199
- const client = createGittermClient();
119
+ client.auth.status(); // -> { userId, email, name, plan, authMethod }
120
+ client.workspaces.list(options?); // -> { workspaces, pagination }; filter by status or metadata
121
+ client.workspaces.get(workspace); // workspace = id string or any object with an `id`
122
+ client.workspaces.getRuntimeAccess(workspace); // read-only; never resumes compute
123
+ client.workspaces.ensureRunning(workspace, options?); // resume if paused, wait until running
124
+ client.workspaces.setupStatus(workspace);
125
+ client.workspaces.waitForSetup(workspace, options?);
126
+ client.workspaces.pause(workspace); // -> { durationMinutes } of the usage session just closed
127
+ client.workspaces.restart(workspace);
128
+ client.workspaces.terminate(workspace); // -> { workspace, cleanupInBackground }
129
+ client.workspaces.create({ repo: "https://github.com/acme/product" });
130
+ client.runs.create(input);
131
+ client.runs.list(workspace, options?); // -> { runs, pagination }
132
+ client.runs.get(run); // run = AgentRun or any { workspaceId, id }
133
+ client.runs.messages(run);
134
+ client.runs.cancel(run);
135
+ client.runs.watch(run, options?); // AsyncIterable<AgentRun>: every lifecycle state until terminal
136
+ client.runs.wait(run, options?); // first state that is terminal or awaiting_input
137
+ client.runs.result(run, options?); // successful final result; optional input handlers
138
+ client.runs.events(run, options?); // actionable, subscriber-deduplicated input/lifecycle events
139
+ client.runs.respond(run, { requestId, reply }); // answer a permission prompt or agent question
140
+ client.catalog.agentTypes();
141
+ client.catalog.cloudProviders();
142
+ client.catalog.workspaceOptions();
143
+ client.credentials.list(); // dashboard credential metadata, never secrets
144
+ client.credentials.listProviders();
145
+ client.models.list({ provider: "openai" }); // discover provider/model IDs
146
+ client.workspaces.models(workspace); // resolved sources + known models; no secrets or runtime wake-up
200
147
  ```
201
148
 
202
- ### CLI saved login
203
-
204
- If you omit both options, the SDK also reads `~/.config/gitterm/cli.json` written by
205
- `gitterm login` / `gitterm login --server <url>`.
206
-
207
- **Resolution order:** constructor options → `GITTERM_SERVER_URL` / `GITTERM_API_TOKEN` → CLI config file.
208
-
209
- Create tokens in the dashboard under **Settings → Account → API tokens**, or via
210
- `gitterm login` (device-code flow). Tokens are the same `gt_...` shape on hosted and
211
- self-hosted.
149
+ Every poll-based wait (`ensureRunning`, `waitForSetup`, and `runs.create` with `waitForSetup`)
150
+ accepts `{ timeoutMs, pollIntervalMs, signal }`. `runs.watch` is push-based (server-sent events)
151
+ and accepts `{ signal }`; `runs.wait` adds `{ timeoutMs, until }`. An `AbortSignal` stops any of
152
+ them with code `ABORTED`; an elapsed `timeoutMs` rejects with code `TIMEOUT`.
212
153
 
213
- ## Usage
214
-
215
- ### With an explicit API token
216
-
217
- ```ts
218
- import { createGittermClient } from "@gitterm/sdk";
219
-
220
- const client = createGittermClient({
221
- token: process.env.GITTERM_API_TOKEN,
222
- });
154
+ `pause()` returns `durationMinutes`, the length of the usage session it just closed, which is
155
+ what billing records. `terminate()` returns `cleanupInBackground: true` when the provider
156
+ finishes tearing down resources after the call returns.
223
157
 
224
- const { workspaces } = await client.workspaces.list();
225
- ```
158
+ ## Workspaces
226
159
 
227
160
  ### Managed private repositories
228
161
 
@@ -258,7 +191,7 @@ const { workspace, runtime } = await client.workspaces.create({
258
191
  The username defaults to `x-access-token`. Inline repository credentials take precedence over
259
192
  `gitIntegrationId` and authenticate repository validation, cloning, and runtime Git operations such
260
193
  as pull and push. Without inline credentials, `gitIntegrationId` continues to use the connected
261
- dashboard integration. Omitting `modelCredentials` likewise continues to
194
+ dashboard integration. Omitting `models` likewise continues to
262
195
  use dashboard-managed model credentials.
263
196
 
264
197
  GitTerm does not save inline PATs in its application database. Inline PATs must be delivered to the
@@ -282,52 +215,7 @@ const preview = await workspace.ports.open(3000, { name: "app" });
282
215
  The workspace client never reads the CLI's saved account login and has no create, list,
283
216
  pause, restart, or terminate operations.
284
217
 
285
- ### With the CLI's saved login
286
-
287
- ```ts
288
- const client = createGittermClient();
289
- const status = await client.auth.status();
290
- // status + client.serverUrl show which account and server you hit
291
- ```
292
-
293
- ### API
294
-
295
- ```ts
296
- client.auth.status(); // -> { userId, email, name, plan, authMethod }
297
- client.workspaces.list(options?); // -> { workspaces, pagination }; filter by status or metadata
298
- client.workspaces.get(workspace); // workspace = id string or any object with an `id`
299
- client.workspaces.getRuntimeAccess(workspace); // read-only; never resumes compute
300
- client.workspaces.ensureRunning(workspace, options?); // resume if paused, wait until running
301
- client.workspaces.setupStatus(workspace);
302
- client.workspaces.waitForSetup(workspace, options?);
303
- client.workspaces.pause(workspace); // -> { durationMinutes } of the usage session just closed
304
- client.workspaces.restart(workspace);
305
- client.workspaces.terminate(workspace); // -> { workspace, cleanupInBackground }
306
- client.workspaces.create({
307
- repo: "https://github.com/acme/product",
308
- });
309
- client.runs.create(input);
310
- client.runs.list(workspace, options?); // -> { runs, pagination }
311
- client.runs.get(run); // run = AgentRun, { workspaceId, runId }, or (workspaceId, runId)
312
- client.runs.messages(run);
313
- client.runs.cancel(run);
314
- client.runs.wait(run, options?); // resolves when terminal or awaiting_input (push-based)
315
- client.runs.respond(run, { requestId, reply }); // answer a permission prompt or agent question
316
- client.catalog.agentTypes();
317
- client.catalog.cloudProviders();
318
- client.catalog.workspaceOptions();
319
- ```
320
-
321
- Every wait (`ensureRunning`, `waitForSetup`, and `runs.create` with `waitForSetup`) accepts
322
- `{ timeoutMs, pollIntervalMs, signal }`. `runs.wait` is push-based (server-sent events) and
323
- accepts `{ timeoutMs, signal, until }`. Pass an `AbortSignal` to stop waiting on shutdown; the
324
- promise rejects with code `ABORTED`.
325
-
326
- `pause()` returns `durationMinutes`, the length of the usage session it just closed, which is
327
- what billing records. `terminate()` returns `cleanupInBackground: true` when the provider
328
- finishes tearing down resources after the call returns.
329
-
330
- #### Tagging and lifetime
218
+ ### Tagging and lifetime
331
219
 
332
220
  `metadata` attaches caller-owned tags to a workspace so you can find it again without a
333
221
  lookup table of your own, and `autoTerminateAfterMs` caps how long it can live:
@@ -350,7 +238,7 @@ and `list` matches workspaces containing every given pair. `autoTerminateAfterMs
350
238
  activity, and `workspace.autoTerminateAt` shows the deadline. Idle workspaces are still paused
351
239
  by the platform's idle policy independently of this.
352
240
 
353
- #### Bring your own image
241
+ ### Bring your own image
354
242
 
355
243
  Pass `image` to run your own build instead of the catalog image. On the managed service the
356
244
  image must be pullable without credentials; creation fails immediately with the reason if it
@@ -508,12 +396,12 @@ The shortest correct sequence is therefore:
508
396
  ```ts
509
397
  const { workspace } = await client.workspaces.create({ repo, setup: { afterAgent: ["npm ci"] } });
510
398
  const run = await client.runs.create({
511
- workspaceId: workspace.id,
399
+ workspace,
512
400
  idempotencyKey: `review-${sha}`,
513
401
  waitForSetup: true,
514
402
  prompt: "Review the open pull request",
515
403
  });
516
- const done = await client.runs.wait(workspace.id, run.id);
404
+ const done = await client.runs.result(run);
517
405
  ```
518
406
 
519
407
  For a workspace you didn't just create, start with `ensureRunning()`:
@@ -532,7 +420,7 @@ Direct provider mode behaves differently: `direct.workspaces.create()` waits for
532
420
  runtime to answer before returning, so a direct workspace is ready for `runs.create()` as soon
533
421
  as `create()` resolves.
534
422
 
535
- ### Agent runs
423
+ ## Agent runs
536
424
 
537
425
  Runs use durable GitTerm IDs backed by the workspace's native OpenCode session. Reusing an
538
426
  idempotency key with the same input returns the original run, and terminal results remain
@@ -551,148 +439,186 @@ Statuses: `pending` → `running` (or `retrying` while OpenCode backs off from t
551
439
  provider) → `completed` | `failed` | `cancelled`. A run that stops to ask something is
552
440
  `awaiting_input` until you answer it (below).
553
441
 
554
- `runs.messages()` returns each turn's concatenated `text` plus an ordered `parts` array. Tool
555
- calls appear as `{ type: "tool", tool, status, title, input, output, error }` with output
556
- truncated to 4000 characters, which is usually enough to see why a run went wrong:
442
+ ### Following a run
443
+
444
+ For the common interactive case, let the SDK drive the loop:
557
445
 
558
446
  ```ts
559
- for (const message of await client.runs.messages(run)) {
560
- for (const part of message.parts) {
561
- if (part.type === "tool" && part.status === "error") {
562
- console.log(part.tool, part.title, part.error);
563
- }
447
+ const result = await client.runs.result(run, {
448
+ timeoutMs: 30 * 60_000,
449
+ onPermission: async (request, { signal }) => {
450
+ // Your UI returns "once", "always", or "reject". Never approve implicitly.
451
+ return await askForApproval(request, signal);
452
+ },
453
+ onQuestion: async (request, { signal }) => {
454
+ // Return { answers: { [question.key]: [selectedLabel] } } or { reject: true }.
455
+ return await collectAnswers(request, signal);
456
+ },
457
+ });
458
+ console.log(result.finalText);
459
+ ```
460
+
461
+ The total deadline includes handler time. A timeout or abort stops observation, **not the agent**;
462
+ call `runs.cancel(run)` to stop it. Handlers receive a signal to close their own UI. Handlers are
463
+ not invoked twice for the same request during one `result()` call. Failed/cancelled runs throw
464
+ `AgentRunError` with `RUN_FAILED` / `RUN_CANCELLED`, retaining the run snapshot on `error.run`.
465
+
466
+ For a bot or queue-based relay, use actionable events instead of processing every state snapshot:
467
+
468
+ ```ts
469
+ for await (const event of client.runs.events(run)) {
470
+ if (event.type === "input.required") {
471
+ await client.runs.respond(run, {
472
+ requestId: event.request.id,
473
+ reply: await askHuman(event.request),
474
+ });
564
475
  }
565
476
  }
566
477
  ```
567
478
 
568
- ```ts
569
- const { workspace } = await client.workspaces.create({
570
- repo: "https://github.com/acme/product",
571
- setup: { afterAgent: ["npm install", "npm run db:seed"] },
572
- });
479
+ For a deferred reply, publish the request to your UI/queue instead, persist the run reference and
480
+ request ID, and call `respond()` from the later handler. Event types are `run.status`,
481
+ `input.required`, `input.resolved`, `run.completed`, `run.failed`, and `run.cancelled`.
482
+ Deduplication is per iterator, not durable exactly-once delivery: a new subscriber receives any
483
+ still-pending input again. Persist `(run.id, request.id)` as your UI/queue deduplication key.
484
+ `INPUT_NOT_PENDING` means a stale or already-resolved request; refresh the run rather than retrying
485
+ the answer blindly. The API does not claim that a repeated answer is idempotent.
573
486
 
574
- const run = await client.runs.create({
575
- workspaceId: workspace.id,
576
- idempotencyKey: "onboarding-v2",
577
- waitForSetup: true,
578
- prompt: "Record the new onboarding flow and open a pull request adding it to the changelog.",
579
- });
487
+ `runs.watch(run)` is an async iterable of the run's lifecycle states. It starts with the current
488
+ state, yields a new `AgentRun` whenever the status or the pending inputs change, and ends after
489
+ the terminal state. It is push-based (server-sent events), so there is nothing to poll:
580
490
 
581
- const completed = await client.runs.wait(workspace.id, run.id);
582
- const messages = await client.runs.messages(workspace.id, run.id);
491
+ ```ts
492
+ for await (const state of client.runs.watch(run, { signal })) {
493
+ console.log(state.status);
494
+ }
583
495
  ```
584
496
 
585
- #### Questions and permission prompts
497
+ `runs.wait(run, options?)` is a helper over `watch()` that resolves with the first state that
498
+ needs attention: a terminal state, or `awaiting_input` unless you pass `until: "terminal"`.
499
+ It takes `timeoutMs` (default 30 minutes; rejects with code `TIMEOUT`) and `signal` (rejects
500
+ with `ABORTED`). Use `wait()` when you want a separate timeout per phase, as a chat bot that
501
+ gives the agent twenty minutes per turn but a human thirty minutes per question would;
502
+ otherwise `watch()` with one `AbortSignal` is simpler.
503
+
504
+ ### Questions and permission prompts
586
505
 
587
506
  When the agent calls OpenCode's `question` tool, or a tool needs approval under your
588
- `permission` config, the run becomes `awaiting_input` and `run.pendingInputs` describes what it
589
- is waiting for. `runs.wait()` returns at that point by default; answer with `runs.respond()` and
590
- wait again. This is what makes a chat bot able to relay "should I do A or B?" to a human.
507
+ `permission` config, the run becomes `awaiting_input` and `run.pendingInputs` lists what it is
508
+ waiting for. There is usually one request; there are several when the agent issued parallel tool
509
+ calls that each need approval. Answer each with `runs.respond()`; the run resumes once none
510
+ remain. The exported types are `AgentPermissionRequest`, `AgentQuestionRequest`, and
511
+ `AgentQuestion`.
512
+
513
+ - A **permission** request has `title` (e.g. `bash: rm -rf dist`), `permission`, `patterns`, and
514
+ `always`. Reply `{ type: "permission", response: "once" | "always" | "reject" }`.
515
+ - A **question** request has `questions[]`, each with a `key`, `header`, `question`,
516
+ `options[{ label, description }]`, `multiple`, and `custom`. Reply with the selected option
517
+ **labels** keyed by question `key`, or one free-text string when `custom` is true:
518
+ `{ type: "question", answers: { [question.key]: string[] } }`. Every question needs an entry.
519
+ Or dismiss all of them with `{ type: "question", reject: true }`.
520
+
521
+ The quick start above shows a complete relay. Rejecting a permission or dismissing a question
522
+ ends the turn: OpenCode records a failed tool call and the run finishes. A run left
523
+ `awaiting_input` does not keep its workspace awake; if nobody answers before the workspace's
524
+ idle timeout pauses it, the run is cancelled with `"Workspace paused while waiting for input"`.
525
+ To avoid prompts entirely in headless runs, allow the relevant tools in
526
+ `opencode.config.permission` when creating the workspace.
527
+
528
+ ### Transcript
529
+
530
+ `runs.messages()` returns each turn's concatenated `text` plus an ordered `parts` array. Tool
531
+ calls appear as `{ type: "tool", tool, status, title, input, output, error }` with output
532
+ truncated to 4000 characters, which is usually enough to see why a run went wrong:
591
533
 
592
534
  ```ts
593
- let run = await client.runs.wait(workspace.id, created.id);
594
- while (run.status === "awaiting_input") {
595
- for (const request of run.pendingInputs) {
596
- if (request.kind === "permission") {
597
- // request.title, e.g. "bash: rm -rf dist"; also permission / patterns / always
598
- await client.runs.respond(run, {
599
- requestId: request.id,
600
- reply: { type: "permission", response: "once" }, // "once" | "always" | "reject"
601
- });
602
- } else {
603
- // request.questions[i] has header, question, options[{ label, description }], multiple, custom
604
- const answers = request.questions.map((question) => [question.options[0]!.label]);
605
- await client.runs.respond(run, {
606
- requestId: request.id,
607
- reply: { type: "question", answers },
608
- });
609
- // or: reply: { type: "question", reject: true }
535
+ for (const message of await client.runs.messages(run)) {
536
+ for (const part of message.parts) {
537
+ if (part.type === "tool" && part.status === "error") {
538
+ console.log(part.tool, part.title, part.error);
610
539
  }
611
540
  }
612
- run = await client.runs.wait(workspace.id, created.id);
613
541
  }
614
542
  ```
615
543
 
616
- Rejecting a permission or dismissing a question ends the turn: OpenCode records a failed tool
617
- call and the run finishes. Pass `{ until: "terminal" }` to `runs.wait()` to keep waiting through
618
- `awaiting_input` (for example when a separate process answers). A run left `awaiting_input` does
619
- not keep its workspace awake; if nobody answers before the workspace's idle timeout pauses it,
620
- the run is cancelled with `"Workspace paused while waiting for input"`. To avoid prompts entirely
621
- in headless runs, allow the relevant tools in `opencode.config.permission` when creating the
622
- workspace.
544
+ The lifecycle stream does not mirror OpenCode's native message, tool, or token events. For live
545
+ execution details, use `workspaces.getRuntimeAccess()` with the official OpenCode SDK.
623
546
 
624
- `runs.wait()` uses a lightweight lifecycle stream internally; it does not mirror OpenCode's
625
- native message, tool, or token events. `runs.messages()` provides the persisted transcript at
626
- lifecycle checkpoints and completion. For live execution details, use
627
- `workspaces.getRuntimeAccess()` with the official OpenCode SDK.
547
+ ### Continuing context
628
548
 
629
549
  Runs are isolated by default and can execute in parallel. To preserve conversational context,
630
550
  continue a terminal run; continued runs sharing context must remain sequential:
631
551
 
632
552
  ```ts
633
553
  const next = await client.runs.create({
634
- workspaceId: workspace.id,
554
+ workspace,
635
555
  idempotencyKey: "onboarding-tests-v1",
636
556
  prompt: "Now add tests for that change.",
637
- context: { type: "continue", runId: completed.id },
557
+ context: { type: "continue", run: completed },
638
558
  });
639
559
  ```
640
560
 
641
- ### Model provider credentials
561
+ `runs.cancel(run)` aborts the current run; a run that is `awaiting_input` has its pending
562
+ prompt rejected first. GitTerm keeps the underlying OpenCode session private.
642
563
 
643
- `modelCredentials` takes one entry per provider. Each entry selects where that provider's
644
- credential comes from, and the three forms compose in one array:
564
+ ## Model provider credentials
565
+
566
+ Model selection and credential selection are separate decisions, grouped under `models`:
645
567
 
646
568
  ```ts
647
569
  const { workspace } = await client.workspaces.create({
648
570
  repo: "https://github.com/acme/product",
649
- modelCredentials: [
650
- { providerName: "openai", label: "work" }, // dashboard credential, by label
651
- { providerName: "anthropic" }, // that provider's dashboard default
652
- { providerName: "google", apiKey: process.env.GOOGLE_API_KEY! }, // inline, this workspace only
653
- ],
571
+ models: {
572
+ default: "openai/<model-id>",
573
+ inherit: "none", // only the providers listed here receive credentials
574
+ providers: {
575
+ openai: { source: "saved", label: "work" },
576
+ anthropic: { source: "default" },
577
+ google: { source: "apiKey", apiKey: process.env.GOOGLE_API_KEY! },
578
+ },
579
+ },
654
580
  });
655
- ```
656
-
657
- **Dashboard credentials** are addressed by `providerName` plus the `label` you gave them in the
658
- dashboard (labels are unique per provider). Omit `label` to use the provider's default. The SDK
659
- never returns credential secrets; `client.credentials.list()` returns metadata including labels
660
- if you need to discover them, and requires an API token with the `workspace:write` scope.
661
-
662
- **Inline credentials** pass an API key directly for this workspace only. They are injected into
663
- the provisioned agent and never stored in the dashboard. Use `client.credentials.listProviders()`
664
- for valid provider names; OAuth providers (e.g. GitHub Copilot) can only be connected through
665
- the dashboard.
666
581
 
667
582
  const run = await client.runs.create({
668
- workspaceId: workspace.workspaceId,
669
- model: "anthropic/claude-sonnet-4-20250514",
670
- prompt: "Record before/after videos of the changes in PR #42",
583
+ workspace,
584
+ model: "anthropic/claude-sonnet-4-20250514",
585
+ prompt: "Record before/after videos of the changes in PR #42",
671
586
  });
587
+ ```
588
+
589
+ **Saved credentials use labels, not IDs.** Choose `{ source: "saved", label: "work" }` or
590
+ `{ source: "default" }`. Provider keys are logical model providers: use `openai` for both OpenAI
591
+ API keys and saved ChatGPT subscriptions, not `openai-oauth`. `credentials.list()` returns labels
592
+ and `logicalProviderKey`, never secrets. If an API key and subscription share the same label,
593
+ selection fails explicitly; give them distinct dashboard labels. Discovery requires `workspace:write`.
672
594
 
673
- ````
595
+ **Inline credentials** use `{ source: "apiKey", apiKey }` for this workspace only. A missing or
596
+ blank key fails validation; it never falls back to a saved credential. Keys are injected into
597
+ the sandbox, not saved in the dashboard. `credentials.listProviders()` includes each authentication
598
+ integration's `logicalProviderKey`. `models.list({ provider: "openai" })` discovers model IDs.
599
+ Managed OAuth credentials must be connected through the dashboard.
600
+
601
+ `workspaces.models(workspace)` shows which sources were configured and the matching catalog models.
602
+ It reads control-plane metadata only: it does not verify a key with the model provider, discover
603
+ custom runtime models, or resume a paused workspace. A saved credential's label/active status is
604
+ its current dashboard metadata.
674
605
 
675
606
  Rules and errors:
676
607
 
677
- - Omit `modelCredentials` to inject every dashboard default.
678
- - Naming any dashboard credential (an entry without `apiKey`) switches off the implicit defaults,
679
- so list each provider the workspace needs. Inline-only arrays keep the defaults for the other
680
- providers.
681
- - One credential per logical provider. An inline credential always overrides the dashboard
682
- credential for the same provider; two entries for the same provider throw
683
- `MODEL_CREDENTIAL_DUPLICATE_PROVIDER`.
608
+ - Omitting `models` uses dashboard defaults. An explicit `models` block defaults to
609
+ `inherit: "none"`; `models: {}` injects no dashboard credentials.
610
+ - Use `inherit: "defaults"` to inherit every unlisted provider's dashboard default. Explicit
611
+ sources override only their own provider. Each provider map key selects exactly one source.
684
612
  - Unknown providers or inline keys for OAuth-only providers throw `MODEL_CREDENTIAL_INVALID`.
685
- A label that doesn't exist throws `MODEL_CREDENTIAL_UNAVAILABLE` and the message lists the
686
- labels that do.
613
+ A missing or ambiguous label throws `MODEL_CREDENTIAL_UNAVAILABLE`.
687
614
  - A run that requests a credential-backed `provider/model` not available in its workspace throws
688
615
  `MODEL_CREDENTIAL_REQUIRED` before the prompt is submitted.
689
616
 
690
- Use `client.runs.cancel(workspaceId, runId)` to abort the current run; a run that is
691
- `awaiting_input` has its pending prompt rejected first. GitTerm keeps the underlying OpenCode
692
- session private. For native session control, use `workspaces.getRuntimeAccess()` and connect
693
- with the official OpenCode SDK.
617
+ `models.default` sets the workspace's agent default; `runs.create({ model })` overrides the model
618
+ for that run. Credentials remain workspace-scoped: changing credentials for one concurrent run
619
+ would otherwise change them for other runs on the same runtime.
694
620
 
695
- ### OpenCode API versions
621
+ ## OpenCode API versions
696
622
 
697
623
  Managed workspaces continue to run `opencode-ai@latest` (`opencodeApi: "v1"`). OpenCode 2
698
624
  changes the server API; callers testing it must supply their own compatible image and create the
@@ -700,32 +626,42 @@ workspace with `opencode: { api: "v2" }`. Runs, questions, permissions, and even
700
626
  same from the SDK's point of view; the flag only tells GitTerm which protocol to speak to the
701
627
  workspace. `v2` is experimental until OpenCode 2 ships.
702
628
 
703
- ### Errors
629
+ ## Errors
704
630
 
705
- Every method throws `GittermError` with a stable `code`:
631
+ Managed methods and the shared run APIs throw `GittermError` with a stable `code`; never match on messages.
632
+ Direct provider lifecycle/authentication methods may also propagate provider errors.
706
633
 
707
634
  ```ts
708
- import { GittermError } from "@gitterm/sdk";
635
+ import { GittermError, WorkspaceLifecycleError } from "@gitterm/sdk";
709
636
 
710
637
  try {
711
- await client.workspaces.get(id);
638
+ await client.runs.create({ workspace: workspaceId, prompt });
712
639
  } catch (error) {
713
- if (error instanceof GittermError && error.code === "NOT_LOGGED_IN") {
714
- // "Not logged in. Run: gitterm login"
715
- }
640
+ if (error instanceof WorkspaceLifecycleError && error.code === "WORKSPACE_NOT_RUNNING") {
641
+ await client.workspaces.ensureRunning(workspaceId); // paused; resume and retry
642
+ } else if (error instanceof GittermError && error.code === "TIMEOUT") {
643
+ // the wait elapsed; the run itself may still be going
644
+ } else throw error;
716
645
  }
717
- ````
646
+ ```
718
647
 
719
- Workspace lifecycle failures are also exposed as `WorkspaceLifecycleError`, with stable
720
- `WORKSPACE_NOT_RUNNING`, `WORKSPACE_TERMINATED`, `WORKSPACE_NON_RECOVERABLE`,
721
- `WORKSPACE_START_TIMEOUT`, and `WORKSPACE_RESTART_FAILED` codes. General codes are
722
- `NOT_LOGGED_IN`, `UNAUTHORIZED`, `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`, `CONFLICT`,
723
- `SERVER_ERROR`, `NETWORK`, and `ABORTED` (a wait was cancelled through its `signal`).
648
+ Workspace lifecycle failures are `WorkspaceLifecycleError` with codes `WORKSPACE_NOT_RUNNING`,
649
+ `WORKSPACE_TERMINATED`, `WORKSPACE_NON_RECOVERABLE`, `WORKSPACE_START_TIMEOUT`, and
650
+ `WORKSPACE_RESTART_FAILED`. Credential failures use the `MODEL_CREDENTIAL_*` codes listed above.
651
+ General codes are `NOT_LOGGED_IN`, `UNAUTHORIZED`, `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`,
652
+ `CONFLICT`, `SERVER_ERROR`, `NETWORK`, `TIMEOUT` (a `timeoutMs` elapsed), and `ABORTED` (a wait
653
+ was cancelled through its `signal`).
724
654
 
725
655
  The package ships self-contained declarations from `dist`; TypeScript consumers do not
726
656
  need GitTerm's API package or tRPC server types.
727
657
 
728
- ### Obtaining a token programmatically
658
+ ## Versioning
659
+
660
+ `@gitterm/sdk` follows semver. Before 1.0, a minor bump (`0.1` → `0.2`) may contain breaking
661
+ changes and lists them in [CHANGELOG.md](./CHANGELOG.md) with a migration note; patch releases
662
+ never change public types or behaviour. Pin `~0.2.0` if you want patches only.
663
+
664
+ ## Obtaining a token programmatically
729
665
 
730
666
  The device-code flow used by `gitterm login` is exposed for integrations. Pass the
731
667
  server URL of the instance you want to log into:
@@ -753,6 +689,170 @@ await saveConfig({
753
689
  Device-code logins produce the same revocable `gt_...` API token as the dashboard;
754
690
  they appear in **Settings → Account → API tokens** and can be revoked there.
755
691
 
692
+ ## Direct provider mode
693
+
694
+ Direct mode runs an agent using your cloud-provider account without a Gitterm server. It intentionally omits managed billing, proxying, policy, durable run history, and automatic cleanup; your application owns workspace state and lifecycle.
695
+
696
+ All built-in compute providers use the same provisioning plan and workspace/run API:
697
+
698
+ | Provider | Direct prerequisite | Persistent pause | Keep-alive |
699
+ | -------- | -------------------------------------------------------------- | ---------------- | ---------- |
700
+ | E2B | OpenCode-compatible template | Yes | Yes |
701
+ | Daytona | Public Gitterm OpenCode server image by default | Yes | Yes |
702
+ | Vercel | Vercel Sandbox project | Yes | Yes |
703
+ | Ascii | Box API key | Yes | Yes |
704
+ | exe.dev | Lifecycle token, or an existing VM with `ls,ssh,share,ssh-key` | Yes | No |
705
+ | Railway | Project/environment and public service domains | With a volume | No |
706
+
707
+ AWS remains available through `createGittermClient()` and the Gitterm control plane; it is intentionally not exposed in direct mode.
708
+
709
+ Cloudflare remains available through the Gitterm control plane. Direct Cloudflare support is deferred until the OpenCode v2 Workerd runtime is stable.
710
+
711
+ ```ts
712
+ import { createDirectGittermClient } from "@gitterm/sdk/direct";
713
+
714
+ const direct = createDirectGittermClient({
715
+ provider: {
716
+ type: "e2b",
717
+ apiKey: process.env.E2B_API_KEY!,
718
+ size: "standard",
719
+ },
720
+ });
721
+
722
+ let workspace = await direct.workspaces.create({
723
+ repo: "https://github.com/acme/project",
724
+ lifecycle: "ephemeral",
725
+ models: {
726
+ providers: { anthropic: { source: "apiKey", apiKey: process.env.ANTHROPIC_API_KEY! } },
727
+ },
728
+ });
729
+
730
+ try {
731
+ const run = await direct.runs.create({ workspace, prompt: "Review the open pull request" });
732
+ const completed = await direct.runs.result(run);
733
+ console.log(completed.finalText);
734
+ } finally {
735
+ workspace = await direct.workspaces.terminate(workspace);
736
+ }
737
+ ```
738
+
739
+ `DirectWorkspace` and `DirectRun` are JSON-serializable. A direct run carries its workspace runtime
740
+ access, so `get`, `watch`, `events`, `wait`, `result`, `respond`, `messages`, and `cancel` take just
741
+ the run, exactly as in managed mode. Persist the complete run to reattach after a process restart;
742
+ continue a completed run with `context: { type: "continue", run: completed }`. After resuming a
743
+ workspace, update a persisted run's `workspace` with the returned workspace before reattaching.
744
+ Direct mode does not provide managed durable run storage or submission idempotency.
745
+ Serialized workspaces/runs contain runtime credentials: encrypt them and never send them to an
746
+ untrusted UI. Custom providers can implement `DirectProviderAdapter`; inspect `client.provider.capabilities`.
747
+
748
+ Both modes default to OpenCode **v1** and share v1/v2 run, permission, question, and SSE adapters.
749
+ For direct v2, supply a compatible provider image/template and `opencode: { api: "v2" }` at workspace
750
+ creation. The flag chooses the protocol; it does not install or upgrade the runtime. Direct mode
751
+ does not accept saved/dashboard credential sources or `inherit: "defaults"`.
752
+
753
+ Every adapter receives the same normalized plan: repository/ref and optional Git credentials, agent files, model credentials, environment, setup commands, serve command, and port. Provider-specific configuration only describes how to allocate and expose compute.
754
+
755
+ Direct setup has explicit phases. `beforeAgent` blocks workspace creation, while
756
+ `afterAgent` runs in the background and can be observed with `setupStatus()` or
757
+ `waitForSetup()`:
758
+
759
+ ```ts
760
+ const workspace = await direct.workspaces.create({
761
+ repo: "https://github.com/acme/project",
762
+ setup: {
763
+ beforeAgent: ["npm install"],
764
+ afterAgent: ["npm run generate"],
765
+ },
766
+ secretFiles: [
767
+ {
768
+ path: "~/.config/gcloud/service-account.json",
769
+ content: process.env.GCP_SERVICE_ACCOUNT_JSON!,
770
+ mode: 0o600,
771
+ },
772
+ ],
773
+ });
774
+
775
+ await direct.workspaces.waitForSetup(workspace);
776
+ ```
777
+
778
+ To attach to an existing exe.dev VM without giving Gitterm ownership of that VM, pass
779
+ `exedev: { existingVmName: "acme-agent-machine" }` to `workspaces.create()`. Terminating
780
+ that workspace stops only its tracked agent process and does not remove the VM.
781
+
782
+ Trusted integration context can be appended to the generated global `AGENTS.md` without changing the model system prompt:
783
+
784
+ ```ts
785
+ await direct.workspaces.create({
786
+ repo: "https://github.com/acme/project",
787
+ additionalAgentInstructions:
788
+ "You are running as a Slack bot. Keep responses concise and suitable for a thread.",
789
+ });
790
+ ```
791
+
792
+ ### Provider authentication
793
+
794
+ Direct **v2** workspaces can start OpenCode provider authentication without shell access. This
795
+ connection-management API is v2-only; v1 supports inline API keys/OAuth bundles at creation and
796
+ `auth.setCredential()`. Discover a headless/device-code method for remote authentication:
797
+
798
+ ```ts
799
+ const openai = await direct.auth.get(workspace, "openai");
800
+ const method = openai.methods.find(
801
+ (item) => item.type === "oauth" && item.id === "chatgpt-headless",
802
+ );
803
+ if (!method || method.type !== "oauth") throw new Error("OpenAI device OAuth is unavailable");
804
+
805
+ const attempt = await direct.auth.connectOAuth({
806
+ workspace,
807
+ integrationId: "openai",
808
+ methodId: method.id,
809
+ label: "Slack bot",
810
+ });
811
+
812
+ // Present these through your application UI.
813
+ console.log(attempt.url, attempt.instructions);
814
+
815
+ if (attempt.mode === "auto") {
816
+ await direct.auth.wait(attempt, workspace);
817
+ } else {
818
+ await direct.auth.complete(attempt, workspace, await getCodeFromUser());
819
+ }
820
+ ```
821
+
822
+ OAuth started this way is stored and refreshed by OpenCode inside the workspace. Reusing a persistent workspace avoids repeated authentication; terminating an ephemeral workspace also destroys its credential store. OpenCode does not export OAuth tokens from this flow.
823
+
824
+ Applications that own OAuth separately can keep the token bundle in encrypted storage and inject it into every new workspace instead:
825
+
826
+ ```ts
827
+ const credential = await credentialStore.get(slackInstallationId);
828
+ const workspace = await direct.workspaces.create({
829
+ lifecycle: "ephemeral",
830
+ models: {
831
+ providers: {
832
+ openai: {
833
+ source: "oauth",
834
+ refreshToken: credential.refreshToken,
835
+ accessToken: credential.accessToken,
836
+ expiresAt: credential.expiresAt,
837
+ accountId: credential.accountId,
838
+ },
839
+ },
840
+ },
841
+ });
842
+
843
+ // v1 credentials can also be added or rotated on an existing runtime.
844
+ // On v2, setCredential supports API keys; use connectOAuth for OAuth rotation.
845
+ await direct.auth.setCredential(workspace, {
846
+ source: "oauth",
847
+ providerName: "openai",
848
+ refreshToken: credential.refreshToken,
849
+ accessToken: credential.accessToken,
850
+ expiresAt: credential.expiresAt,
851
+ });
852
+ ```
853
+
854
+ In this mode the application owns encryption, tenant scoping, refresh, and persistence. OpenCode may refresh its workspace-local copy; the direct SDK does not copy rotated tokens back into application storage. Use the Gitterm control plane when those credential-management responsibilities should be managed centrally.
855
+
756
856
  ## License
757
857
 
758
858
  MIT