@gitterm/sdk 0.1.2 → 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
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.
7
+ ## Install
10
8
 
11
- All built-in compute providers use the same provisioning plan and workspace/run API:
9
+ ```sh
10
+ bun add @gitterm/sdk
11
+ # or
12
+ npm install @gitterm/sdk
13
+ ```
12
14
 
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 |
15
+ Requires Node 22.12+ or Bun. Create an API token in the dashboard under
16
+ **Settings → Account → API tokens**, or with `gitterm login`.
21
17
 
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.
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.
55
51
 
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()`:
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:
59
54
 
60
55
  ```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.
82
-
83
- Trusted integration context can be appended to the generated global `AGENTS.md` without changing the model system prompt:
84
-
85
- ```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
69
 
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
-
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)
162
-
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`.
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).
165
96
 
166
- | Deployment | Example `serverUrl` |
167
- | ----------- | ----------------------------- |
168
- | Hosted | `https://api.gitterm.dev` |
169
- | Self-hosted | `https://gitterm.example.com` |
170
- | Local dev | `http://localhost:3000` |
97
+ ## Configuration
171
98
 
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.
212
-
213
- ## Usage
214
-
215
- ### With an explicit API token
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`.
216
153
 
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 `modelCredentialIds` and `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,33 +215,69 @@ 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
218
+ ### Tagging and lifetime
219
+
220
+ `metadata` attaches caller-owned tags to a workspace so you can find it again without a
221
+ lookup table of your own, and `autoTerminateAfterMs` caps how long it can live:
286
222
 
287
223
  ```ts
288
- const client = createGittermClient();
289
- const status = await client.auth.status();
290
- // status + client.serverUrl show which account and server you hit
224
+ await client.workspaces.create({
225
+ repo: "https://github.com/acme/product",
226
+ metadata: { tenant: "acme", channel: "C0123" },
227
+ autoTerminateAfterMs: 2 * 60 * 60 * 1000, // gone in 2h even if this process crashes
228
+ });
229
+
230
+ const { workspaces } = await client.workspaces.list({
231
+ metadata: { tenant: "acme", channel: "C0123" },
232
+ });
291
233
  ```
292
234
 
293
- ### API
235
+ Metadata allows up to 20 keys of letters, digits, `_ . : -`, with values up to 500 characters,
236
+ and `list` matches workspaces containing every given pair. `autoTerminateAfterMs` ranges from
237
+ 1 minute to 30 days; the reaper terminates the workspace once the time passes regardless of
238
+ activity, and `workspace.autoTerminateAt` shows the deadline. Idle workspaces are still paused
239
+ by the platform's idle policy independently of this.
240
+
241
+ ### Bring your own image
242
+
243
+ Pass `image` to run your own build instead of the catalog image. On the managed service the
244
+ image must be pullable without credentials; creation fails immediately with the reason if it
245
+ isn't, rather than leaving a workspace stuck pulling.
294
246
 
295
247
  ```ts
296
- client.auth.status(); // -> { userId, email, name, plan, authMethod }
297
- client.workspaces.list(options?); // -> { workspaces, pagination }
298
- client.workspaces.get(workspaceId);
299
- client.workspaces.getRuntimeAccess(workspaceId); // read-only; never resumes compute
300
- client.workspaces.ensureRunning(workspaceId, options?);
301
- client.workspaces.pause(workspaceId);
302
- client.workspaces.restart(workspaceId);
303
- client.workspaces.terminate(workspaceId);
304
- client.workspaces.create({
248
+ await client.workspaces.create({
305
249
  repo: "https://github.com/acme/product",
250
+ provider: { type: "railway" },
251
+ image: "ghcr.io/acme/agent-runner:1.4.0",
252
+ });
253
+
254
+ // E2B takes a public template id or alias instead of a registry reference.
255
+ await client.workspaces.create({
256
+ repo: "https://github.com/acme/product",
257
+ provider: { type: "e2b" },
258
+ image: "acme-python-runner",
306
259
  });
307
- client.catalog.agentTypes();
308
- client.catalog.cloudProviders();
309
- client.catalog.workspaceOptions();
310
260
  ```
311
261
 
262
+ Supported on Railway, AWS, Daytona, exe.dev, and E2B. Vercel and Cloudflare run fixed
263
+ runtimes and reject `image`. Floating tags such as `latest` are pinned to the digest verified
264
+ at create time, so a restart months later runs the same image; `workspace.customImage` shows
265
+ what was pinned.
266
+
267
+ The image has to behave like the stock one, because GitTerm's entrypoint does the clone, writes
268
+ agent files, runs setup phases, and starts the agent. Layer on the published base and keep its
269
+ entrypoint:
270
+
271
+ ```dockerfile
272
+ FROM opeoginni/gitterm-opencode-server:latest
273
+ RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-pip poppler-utils \
274
+ && rm -rf /var/lib/apt/lists/*
275
+ ```
276
+
277
+ Rules: do not override `ENTRYPOINT` or `CMD`; keep global installs outside `/workspace`, which
278
+ is a persisted volume; keep `opencode` and `@gitterm/cli` on `PATH`; keep port 7681. For E2B,
279
+ build your template from the same base and publish it so any account can start it.
280
+
312
281
  The server defaults the agent to `opencode`, selects the user's preferred provider,
313
282
  uses that provider's default machine profile, and applies the provider's persistence policy.
314
283
  Override only the placement decisions your integration cares about:
@@ -385,122 +354,314 @@ run UI review or browser capture tools in the sandbox, upload the resulting medi
385
354
  the changelog in the checked-out repository, then terminate the workspace. Use an
386
355
  `idempotencyKey` based on the release SHA when the workflow may be retried.
387
356
 
388
- ### Agent runs
357
+ ### Workspace lifecycle and readiness
358
+
359
+ A workspace has one of four statuses:
360
+
361
+ | Status | Meaning |
362
+ | ------------ | ----------------------------------------------------------------------------------------- |
363
+ | `pending` | Compute is being provisioned or resumed. `runtime.url` is `null`. |
364
+ | `running` | The provider reports the sandbox or container up. The agent process may still be booting. |
365
+ | `paused` | Stopped but resumable. |
366
+ | `terminated` | Gone for good. |
367
+
368
+ Whether `create()` returns `running` or `pending` depends on the provider. Sandbox providers
369
+ (E2B, Daytona, Vercel, Ascii, exe.dev, Cloudflare) settle immediately and return `running`.
370
+ Railway settles by webhook and returns `pending` until its deployment reports success, usually
371
+ within a minute. Code that only ever ran against a sandbox provider will see `pending` for the
372
+ first time when it moves to Railway.
373
+
374
+ Three things have to be true before a prompt can be delivered, and the SDK handles each:
375
+
376
+ 1. **The workspace is `running`.** `runs.create()` waits for a `pending` workspace to
377
+ transition on its own, up to `startTimeoutMs` (default 120s). It does not resume a `paused`
378
+ workspace; call `ensureRunning()` for that, which resumes, polls until `running`, and returns
379
+ the runtime access.
380
+ 2. **The agent answers.** `running` is the provider's view. `runs.create()` then probes the
381
+ workspace URL until the agent server responds before submitting the prompt, so callers never
382
+ need their own health check.
383
+ 3. **Setup finished (optional).** `afterAgent` commands run in the background once the agent is
384
+ reachable. Pass `waitForSetup: true` to `runs.create()` to block on them, or poll with
385
+ `setupStatus()` / `waitForSetup()`. Status `waiting` means the agent isn't reachable yet, so
386
+ setup hasn't started; `running`, `succeeded`, and `failed` describe the phase itself;
387
+ `not_requested` means there were no `afterAgent` commands. The SDK polls setup from the
388
+ client so a long `npm ci` never holds one HTTP request open; that polling needs the
389
+ `workspace:read` scope on the token in addition to `run:write`. `waiting` cannot last
390
+ forever: while you poll, the server also reads the setup state files inside the workspace
391
+ (through the agent when the provider has no exec channel), and if nothing has progressed
392
+ after 15 minutes it marks the setup `failed` with a log explaining what stalled.
393
+
394
+ The shortest correct sequence is therefore:
395
+
396
+ ```ts
397
+ const { workspace } = await client.workspaces.create({ repo, setup: { afterAgent: ["npm ci"] } });
398
+ const run = await client.runs.create({
399
+ workspace,
400
+ idempotencyKey: `review-${sha}`,
401
+ waitForSetup: true,
402
+ prompt: "Review the open pull request",
403
+ });
404
+ const done = await client.runs.result(run);
405
+ ```
406
+
407
+ For a workspace you didn't just create, start with `ensureRunning()`:
408
+
409
+ ```ts
410
+ const { workspace, runtime } = await client.workspaces.ensureRunning(workspaceId);
411
+ // runtime.url is set; workspace.status is "running"
412
+ ```
413
+
414
+ Failures surface as `WorkspaceLifecycleError` with stable codes: `WORKSPACE_NOT_RUNNING` (the
415
+ workspace is `paused`, or stopped while waiting), `WORKSPACE_TERMINATED`,
416
+ `WORKSPACE_START_TIMEOUT` (still `pending` after the timeout, or no runtime URL),
417
+ `WORKSPACE_NON_RECOVERABLE`, and `WORKSPACE_RESTART_FAILED`.
418
+
419
+ Direct provider mode behaves differently: `direct.workspaces.create()` waits for the OpenCode
420
+ runtime to answer before returning, so a direct workspace is ready for `runs.create()` as soon
421
+ as `create()` resolves.
422
+
423
+ ## Agent runs
389
424
 
390
425
  Runs use durable GitTerm IDs backed by the workspace's native OpenCode session. Reusing an
391
426
  idempotency key with the same input returns the original run, and terminal results remain
392
- available after the workspace is paused. Completion means the native session became idle;
393
- it does not claim that a pull request, upload, or other product outcome succeeded.
427
+ available after the workspace is paused. `idempotencyKey` defaults to a random UUID; pass your
428
+ own whenever a retry could otherwise submit the same prompt twice. Completion means the native
429
+ session became idle; it does not claim that a pull request, upload, or other product outcome
430
+ succeeded.
431
+
432
+ Every run carries `createdAt`, `submittedAt` (when the prompt reached the agent), and
433
+ `completedAt`. `runs.list(workspace, { status: "active" })` returns what is still in flight, so a
434
+ process recovering after a restart can pick up where it left off without having persisted run
435
+ ids itself. The server watches every active run's OpenCode session and keeps the run current;
436
+ `get`/`list`/`messages` read that state without touching the workspace.
437
+
438
+ Statuses: `pending` → `running` (or `retrying` while OpenCode backs off from the model
439
+ provider) → `completed` | `failed` | `cancelled`. A run that stops to ask something is
440
+ `awaiting_input` until you answer it (below).
441
+
442
+ ### Following a run
443
+
444
+ For the common interactive case, let the SDK drive the loop:
394
445
 
395
446
  ```ts
396
- const { workspace } = await client.workspaces.create({
397
- repo: "https://github.com/acme/product",
398
- setup: { afterAgent: ["npm install", "npm run db:seed"] },
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
+ },
399
457
  });
458
+ console.log(result.finalText);
459
+ ```
400
460
 
401
- const run = await client.runs.create({
402
- workspaceId: workspace.id,
403
- idempotencyKey: "onboarding-v2",
404
- waitForSetup: true,
405
- prompt: "Record the new onboarding flow and open a pull request adding it to the changelog.",
406
- });
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
+ });
475
+ }
476
+ }
477
+ ```
478
+
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.
407
486
 
408
- const completed = await client.runs.wait(workspace.id, run.id);
409
- const messages = await client.runs.messages(workspace.id, run.id);
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:
490
+
491
+ ```ts
492
+ for await (const state of client.runs.watch(run, { signal })) {
493
+ console.log(state.status);
494
+ }
410
495
  ```
411
496
 
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
505
+
506
+ When the agent calls OpenCode's `question` tool, or a tool needs approval under your
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:
533
+
534
+ ```ts
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);
539
+ }
540
+ }
541
+ }
542
+ ```
543
+
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.
546
+
547
+ ### Continuing context
548
+
412
549
  Runs are isolated by default and can execute in parallel. To preserve conversational context,
413
550
  continue a terminal run; continued runs sharing context must remain sequential:
414
551
 
415
552
  ```ts
416
553
  const next = await client.runs.create({
417
- workspaceId: workspace.id,
554
+ workspace,
418
555
  idempotencyKey: "onboarding-tests-v1",
419
556
  prompt: "Now add tests for that change.",
420
- context: { type: "continue", runId: completed.id },
557
+ context: { type: "continue", run: completed },
421
558
  });
422
559
  ```
423
560
 
424
- ### 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.
425
563
 
426
- Workspaces can receive model credentials two ways, and they compose:
564
+ ## Model provider credentials
427
565
 
428
- **Dashboard credentials** — list the account's credential metadata, choose one active credential
429
- per provider, and pass the IDs when creating a workspace. The SDK never returns credential
430
- secrets.
566
+ Model selection and credential selection are separate decisions, grouped under `models`:
431
567
 
432
568
  ```ts
433
- const credentials = await client.credentials.list();
434
- const selected = credentials.filter(
435
- (credential) =>
436
- credential.isActive && ["anthropic", "openai"].includes(credential.logicalProviderKey),
437
- );
438
-
439
569
  const { workspace } = await client.workspaces.create({
440
570
  repo: "https://github.com/acme/product",
441
- modelCredentialIds: selected.map((credential) => credential.id),
442
- });
443
- ```
444
-
445
- **Inline credentials** — pass API keys directly for this workspace only. They are injected into
446
- the provisioned agent and never stored in the dashboard. Use
447
- `client.credentials.listProviders()` for valid provider names; OAuth providers (e.g. GitHub
448
- Copilot) can only be connected through the dashboard.
449
-
450
- ```ts
451
- const { workspace } = await client.workspaces.create({
452
- repo: "https://github.com/acme/product",
453
- modelCredentials: [{ providerName: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY! }],
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
+ },
454
580
  });
455
581
 
456
582
  const run = await client.runs.create({
457
- workspaceId: workspace.workspaceId,
583
+ workspace,
458
584
  model: "anthropic/claude-sonnet-4-20250514",
459
585
  prompt: "Record before/after videos of the changes in PR #42",
460
586
  });
461
587
  ```
462
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`.
594
+
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.
605
+
463
606
  Rules and errors:
464
607
 
465
- - Omit both fields to inject the dashboard defaults.
466
- - One credential per logical provider. An inline credential always overrides the dashboard
467
- credential (default or selected) for the same provider; two credentials for the same provider
468
- within one field throw `MODEL_CREDENTIAL_DUPLICATE_PROVIDER`.
469
- - Unknown providers or inline keys for OAuth-only providers throw `MODEL_CREDENTIAL_INVALID`;
470
- missing, inactive, or unowned dashboard selections throw `MODEL_CREDENTIAL_UNAVAILABLE`.
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.
612
+ - Unknown providers or inline keys for OAuth-only providers throw `MODEL_CREDENTIAL_INVALID`.
613
+ A missing or ambiguous label throws `MODEL_CREDENTIAL_UNAVAILABLE`.
471
614
  - A run that requests a credential-backed `provider/model` not available in its workspace throws
472
615
  `MODEL_CREDENTIAL_REQUIRED` before the prompt is submitted.
473
616
 
474
- Use `client.runs.cancel(workspaceId, runId)` to abort the current run. GitTerm keeps the
475
- underlying OpenCode session private. For native session control, use
476
- `workspaces.getRuntimeAccess()` and connect 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.
620
+
621
+ ## OpenCode API versions
622
+
623
+ Managed workspaces continue to run `opencode-ai@latest` (`opencodeApi: "v1"`). OpenCode 2
624
+ changes the server API; callers testing it must supply their own compatible image and create the
625
+ workspace with `opencode: { api: "v2" }`. Runs, questions, permissions, and events behave the
626
+ same from the SDK's point of view; the flag only tells GitTerm which protocol to speak to the
627
+ workspace. `v2` is experimental until OpenCode 2 ships.
477
628
 
478
- ### Errors
629
+ ## Errors
479
630
 
480
- 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.
481
633
 
482
634
  ```ts
483
- import { GittermError } from "@gitterm/sdk";
635
+ import { GittermError, WorkspaceLifecycleError } from "@gitterm/sdk";
484
636
 
485
637
  try {
486
- await client.workspaces.get(id);
638
+ await client.runs.create({ workspace: workspaceId, prompt });
487
639
  } catch (error) {
488
- if (error instanceof GittermError && error.code === "NOT_LOGGED_IN") {
489
- // "Not logged in. Run: gitterm login"
490
- }
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;
491
645
  }
492
646
  ```
493
647
 
494
- Workspace lifecycle failures are also exposed as `WorkspaceLifecycleError`, with stable
648
+ Workspace lifecycle failures are `WorkspaceLifecycleError` with codes `WORKSPACE_NOT_RUNNING`,
495
649
  `WORKSPACE_TERMINATED`, `WORKSPACE_NON_RECOVERABLE`, `WORKSPACE_START_TIMEOUT`, and
496
- `WORKSPACE_RESTART_FAILED` codes. General codes are
497
- `NOT_LOGGED_IN`, `UNAUTHORIZED`, `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`, `CONFLICT`,
498
- `SERVER_ERROR`, and `NETWORK`.
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`).
499
654
 
500
655
  The package ships self-contained declarations from `dist`; TypeScript consumers do not
501
656
  need GitTerm's API package or tRPC server types.
502
657
 
503
- ### 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
504
665
 
505
666
  The device-code flow used by `gitterm login` is exposed for integrations. Pass the
506
667
  server URL of the instance you want to log into:
@@ -528,6 +689,170 @@ await saveConfig({
528
689
  Device-code logins produce the same revocable `gt_...` API token as the dashboard;
529
690
  they appear in **Settings → Account → API tokens** and can be revoked there.
530
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
+
531
856
  ## License
532
857
 
533
858
  MIT