@gitterm/sdk 0.1.3 → 0.2.1
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 +443 -341
- package/dist/client.d.ts +19 -9
- package/dist/direct/client.d.ts +15 -14
- package/dist/direct/index.js +1380 -179
- package/dist/direct/runs.d.ts +19 -0
- package/dist/direct/types.d.ts +25 -23
- package/dist/errors.d.ts +7 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +267 -88
- package/dist/runs.d.ts +19 -0
- package/dist/types.d.ts +122 -59
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,228 +1,161 @@
|
|
|
1
1
|
# @gitterm/sdk
|
|
2
2
|
|
|
3
|
-
TypeScript SDK for the [GitTerm](https://gitterm.dev) API
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
##
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
```sh
|
|
10
|
+
bun add @gitterm/sdk
|
|
11
|
+
# or
|
|
12
|
+
npm install @gitterm/sdk
|
|
13
|
+
```
|
|
10
14
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
Create a workspace, run a prompt, and get the final result:
|
|
25
21
|
|
|
26
22
|
```ts
|
|
27
|
-
import {
|
|
23
|
+
import { createGittermClient } from "@gitterm/sdk";
|
|
28
24
|
|
|
29
|
-
const
|
|
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
|
-
|
|
38
|
-
repo: "https://github.com/acme/
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
42
|
+
await client.workspaces.terminate(workspace);
|
|
49
43
|
}
|
|
50
44
|
```
|
|
51
45
|
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
instances use the same SDK — pass your instance’s base URL as `serverUrl`.
|
|
97
|
+
## Configuration
|
|
165
98
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
export GITTERM_SERVER_URL=https://gitterm.example.com
|
|
194
|
-
export GITTERM_API_TOKEN=gt_...
|
|
195
|
-
```
|
|
116
|
+
## API
|
|
196
117
|
|
|
197
118
|
```ts
|
|
198
|
-
//
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,188 @@ 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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
442
|
+
### Following a run
|
|
443
|
+
|
|
444
|
+
For the common interactive case, let the SDK drive the loop:
|
|
557
445
|
|
|
558
446
|
```ts
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
582
|
-
const
|
|
491
|
+
```ts
|
|
492
|
+
for await (const state of client.runs.watch(run, { signal })) {
|
|
493
|
+
console.log(state.status);
|
|
494
|
+
}
|
|
583
495
|
```
|
|
584
496
|
|
|
585
|
-
|
|
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`
|
|
589
|
-
|
|
590
|
-
|
|
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
|
+
`custom` defaults to true, matching OpenCode; false only when the agent sets it.
|
|
521
|
+
Questions with `options: []` accept free-text answers when `custom` is true.
|
|
522
|
+
|
|
523
|
+
The quick start above shows a complete relay. Rejecting a permission or dismissing a question
|
|
524
|
+
ends the turn: OpenCode records a failed tool call and the run finishes. A run left
|
|
525
|
+
`awaiting_input` does not keep its workspace awake; if nobody answers before the workspace's
|
|
526
|
+
idle timeout pauses it, the run is cancelled with `"Workspace paused while waiting for input"`.
|
|
527
|
+
To avoid prompts entirely in headless runs, allow the relevant tools in
|
|
528
|
+
`opencode.config.permission` when creating the workspace.
|
|
529
|
+
|
|
530
|
+
### Transcript
|
|
531
|
+
|
|
532
|
+
`runs.messages()` returns each turn's concatenated `text` plus an ordered `parts` array. Tool
|
|
533
|
+
calls appear as `{ type: "tool", tool, status, title, input, output, error }` with output
|
|
534
|
+
truncated to 4000 characters, which is usually enough to see why a run went wrong:
|
|
591
535
|
|
|
592
536
|
```ts
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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 }
|
|
537
|
+
for (const message of await client.runs.messages(run)) {
|
|
538
|
+
for (const part of message.parts) {
|
|
539
|
+
if (part.type === "tool" && part.status === "error") {
|
|
540
|
+
console.log(part.tool, part.title, part.error);
|
|
610
541
|
}
|
|
611
542
|
}
|
|
612
|
-
run = await client.runs.wait(workspace.id, created.id);
|
|
613
543
|
}
|
|
614
544
|
```
|
|
615
545
|
|
|
616
|
-
|
|
617
|
-
|
|
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.
|
|
546
|
+
The lifecycle stream does not mirror OpenCode's native message, tool, or token events. For live
|
|
547
|
+
execution details, use `workspaces.getRuntimeAccess()` with the official OpenCode SDK.
|
|
623
548
|
|
|
624
|
-
|
|
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.
|
|
549
|
+
### Continuing context
|
|
628
550
|
|
|
629
551
|
Runs are isolated by default and can execute in parallel. To preserve conversational context,
|
|
630
552
|
continue a terminal run; continued runs sharing context must remain sequential:
|
|
631
553
|
|
|
632
554
|
```ts
|
|
633
555
|
const next = await client.runs.create({
|
|
634
|
-
|
|
556
|
+
workspace,
|
|
635
557
|
idempotencyKey: "onboarding-tests-v1",
|
|
636
558
|
prompt: "Now add tests for that change.",
|
|
637
|
-
context: { type: "continue",
|
|
559
|
+
context: { type: "continue", run: completed },
|
|
638
560
|
});
|
|
639
561
|
```
|
|
640
562
|
|
|
641
|
-
|
|
563
|
+
`runs.cancel(run)` aborts the current run; a run that is `awaiting_input` has its pending
|
|
564
|
+
prompt rejected first. GitTerm keeps the underlying OpenCode session private.
|
|
642
565
|
|
|
643
|
-
|
|
644
|
-
|
|
566
|
+
## Model provider credentials
|
|
567
|
+
|
|
568
|
+
Model selection and credential selection are separate decisions, grouped under `models`:
|
|
645
569
|
|
|
646
570
|
```ts
|
|
647
571
|
const { workspace } = await client.workspaces.create({
|
|
648
572
|
repo: "https://github.com/acme/product",
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
573
|
+
models: {
|
|
574
|
+
default: "openai/<model-id>",
|
|
575
|
+
inherit: "none", // only the providers listed here receive credentials
|
|
576
|
+
providers: {
|
|
577
|
+
openai: { source: "saved", label: "work" },
|
|
578
|
+
anthropic: { source: "default" },
|
|
579
|
+
google: { source: "apiKey", apiKey: process.env.GOOGLE_API_KEY! },
|
|
580
|
+
},
|
|
581
|
+
},
|
|
654
582
|
});
|
|
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
583
|
|
|
667
584
|
const run = await client.runs.create({
|
|
668
|
-
|
|
669
|
-
model: "anthropic/claude-sonnet-4-20250514",
|
|
670
|
-
prompt: "Record before/after videos of the changes in PR #42",
|
|
585
|
+
workspace,
|
|
586
|
+
model: "anthropic/claude-sonnet-4-20250514",
|
|
587
|
+
prompt: "Record before/after videos of the changes in PR #42",
|
|
671
588
|
});
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
**Saved credentials use labels, not IDs.** Choose `{ source: "saved", label: "work" }` or
|
|
592
|
+
`{ source: "default" }`. Provider keys are logical model providers: use `openai` for both OpenAI
|
|
593
|
+
API keys and saved ChatGPT subscriptions, not `openai-oauth`. `credentials.list()` returns labels
|
|
594
|
+
and `logicalProviderKey`, never secrets. If an API key and subscription share the same label,
|
|
595
|
+
selection fails explicitly; give them distinct dashboard labels. Discovery requires `workspace:write`.
|
|
672
596
|
|
|
673
|
-
|
|
597
|
+
**Inline credentials** use `{ source: "apiKey", apiKey }` for this workspace only. A missing or
|
|
598
|
+
blank key fails validation; it never falls back to a saved credential. Keys are injected into
|
|
599
|
+
the sandbox, not saved in the dashboard. `credentials.listProviders()` includes each authentication
|
|
600
|
+
integration's `logicalProviderKey`. `models.list({ provider: "openai" })` discovers model IDs.
|
|
601
|
+
Managed OAuth credentials must be connected through the dashboard.
|
|
602
|
+
|
|
603
|
+
`workspaces.models(workspace)` shows which sources were configured and the matching catalog models.
|
|
604
|
+
It reads control-plane metadata only: it does not verify a key with the model provider, discover
|
|
605
|
+
custom runtime models, or resume a paused workspace. A saved credential's label/active status is
|
|
606
|
+
its current dashboard metadata.
|
|
674
607
|
|
|
675
608
|
Rules and errors:
|
|
676
609
|
|
|
677
|
-
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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`.
|
|
610
|
+
- Omitting `models` uses dashboard defaults. An explicit `models` block defaults to
|
|
611
|
+
`inherit: "none"`; `models: {}` injects no dashboard credentials.
|
|
612
|
+
- Use `inherit: "defaults"` to inherit every unlisted provider's dashboard default. Explicit
|
|
613
|
+
sources override only their own provider. Each provider map key selects exactly one source.
|
|
684
614
|
- Unknown providers or inline keys for OAuth-only providers throw `MODEL_CREDENTIAL_INVALID`.
|
|
685
|
-
A
|
|
686
|
-
labels that do.
|
|
615
|
+
A missing or ambiguous label throws `MODEL_CREDENTIAL_UNAVAILABLE`.
|
|
687
616
|
- A run that requests a credential-backed `provider/model` not available in its workspace throws
|
|
688
617
|
`MODEL_CREDENTIAL_REQUIRED` before the prompt is submitted.
|
|
689
618
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
with the official OpenCode SDK.
|
|
619
|
+
`models.default` sets the workspace's agent default; `runs.create({ model })` overrides the model
|
|
620
|
+
for that run. Credentials remain workspace-scoped: changing credentials for one concurrent run
|
|
621
|
+
would otherwise change them for other runs on the same runtime.
|
|
694
622
|
|
|
695
|
-
|
|
623
|
+
## OpenCode API versions
|
|
696
624
|
|
|
697
625
|
Managed workspaces continue to run `opencode-ai@latest` (`opencodeApi: "v1"`). OpenCode 2
|
|
698
626
|
changes the server API; callers testing it must supply their own compatible image and create the
|
|
@@ -700,32 +628,42 @@ workspace with `opencode: { api: "v2" }`. Runs, questions, permissions, and even
|
|
|
700
628
|
same from the SDK's point of view; the flag only tells GitTerm which protocol to speak to the
|
|
701
629
|
workspace. `v2` is experimental until OpenCode 2 ships.
|
|
702
630
|
|
|
703
|
-
|
|
631
|
+
## Errors
|
|
704
632
|
|
|
705
|
-
|
|
633
|
+
Managed methods and the shared run APIs throw `GittermError` with a stable `code`; never match on messages.
|
|
634
|
+
Direct provider lifecycle/authentication methods may also propagate provider errors.
|
|
706
635
|
|
|
707
636
|
```ts
|
|
708
|
-
import { GittermError } from "@gitterm/sdk";
|
|
637
|
+
import { GittermError, WorkspaceLifecycleError } from "@gitterm/sdk";
|
|
709
638
|
|
|
710
639
|
try {
|
|
711
|
-
await client.
|
|
640
|
+
await client.runs.create({ workspace: workspaceId, prompt });
|
|
712
641
|
} catch (error) {
|
|
713
|
-
if (error instanceof
|
|
714
|
-
|
|
715
|
-
}
|
|
642
|
+
if (error instanceof WorkspaceLifecycleError && error.code === "WORKSPACE_NOT_RUNNING") {
|
|
643
|
+
await client.workspaces.ensureRunning(workspaceId); // paused; resume and retry
|
|
644
|
+
} else if (error instanceof GittermError && error.code === "TIMEOUT") {
|
|
645
|
+
// the wait elapsed; the run itself may still be going
|
|
646
|
+
} else throw error;
|
|
716
647
|
}
|
|
717
|
-
|
|
648
|
+
```
|
|
718
649
|
|
|
719
|
-
Workspace lifecycle failures are
|
|
720
|
-
`
|
|
721
|
-
`
|
|
722
|
-
`NOT_LOGGED_IN`, `UNAUTHORIZED`, `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`,
|
|
723
|
-
`SERVER_ERROR`, `NETWORK`,
|
|
650
|
+
Workspace lifecycle failures are `WorkspaceLifecycleError` with codes `WORKSPACE_NOT_RUNNING`,
|
|
651
|
+
`WORKSPACE_TERMINATED`, `WORKSPACE_NON_RECOVERABLE`, `WORKSPACE_START_TIMEOUT`, and
|
|
652
|
+
`WORKSPACE_RESTART_FAILED`. Credential failures use the `MODEL_CREDENTIAL_*` codes listed above.
|
|
653
|
+
General codes are `NOT_LOGGED_IN`, `UNAUTHORIZED`, `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`,
|
|
654
|
+
`CONFLICT`, `SERVER_ERROR`, `NETWORK`, `TIMEOUT` (a `timeoutMs` elapsed), and `ABORTED` (a wait
|
|
655
|
+
was cancelled through its `signal`).
|
|
724
656
|
|
|
725
657
|
The package ships self-contained declarations from `dist`; TypeScript consumers do not
|
|
726
658
|
need GitTerm's API package or tRPC server types.
|
|
727
659
|
|
|
728
|
-
|
|
660
|
+
## Versioning
|
|
661
|
+
|
|
662
|
+
`@gitterm/sdk` follows semver. Before 1.0, a minor bump (`0.1` → `0.2`) may contain breaking
|
|
663
|
+
changes and lists them in [CHANGELOG.md](./CHANGELOG.md) with a migration note; patch releases
|
|
664
|
+
never change public types or behaviour. Pin `~0.2.0` if you want patches only.
|
|
665
|
+
|
|
666
|
+
## Obtaining a token programmatically
|
|
729
667
|
|
|
730
668
|
The device-code flow used by `gitterm login` is exposed for integrations. Pass the
|
|
731
669
|
server URL of the instance you want to log into:
|
|
@@ -753,6 +691,170 @@ await saveConfig({
|
|
|
753
691
|
Device-code logins produce the same revocable `gt_...` API token as the dashboard;
|
|
754
692
|
they appear in **Settings → Account → API tokens** and can be revoked there.
|
|
755
693
|
|
|
694
|
+
## Direct provider mode
|
|
695
|
+
|
|
696
|
+
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.
|
|
697
|
+
|
|
698
|
+
All built-in compute providers use the same provisioning plan and workspace/run API:
|
|
699
|
+
|
|
700
|
+
| Provider | Direct prerequisite | Persistent pause | Keep-alive |
|
|
701
|
+
| -------- | -------------------------------------------------------------- | ---------------- | ---------- |
|
|
702
|
+
| E2B | OpenCode-compatible template | Yes | Yes |
|
|
703
|
+
| Daytona | Public Gitterm OpenCode server image by default | Yes | Yes |
|
|
704
|
+
| Vercel | Vercel Sandbox project | Yes | Yes |
|
|
705
|
+
| Ascii | Box API key | Yes | Yes |
|
|
706
|
+
| exe.dev | Lifecycle token, or an existing VM with `ls,ssh,share,ssh-key` | Yes | No |
|
|
707
|
+
| Railway | Project/environment and public service domains | With a volume | No |
|
|
708
|
+
|
|
709
|
+
AWS remains available through `createGittermClient()` and the Gitterm control plane; it is intentionally not exposed in direct mode.
|
|
710
|
+
|
|
711
|
+
Cloudflare remains available through the Gitterm control plane. Direct Cloudflare support is deferred until the OpenCode v2 Workerd runtime is stable.
|
|
712
|
+
|
|
713
|
+
```ts
|
|
714
|
+
import { createDirectGittermClient } from "@gitterm/sdk/direct";
|
|
715
|
+
|
|
716
|
+
const direct = createDirectGittermClient({
|
|
717
|
+
provider: {
|
|
718
|
+
type: "e2b",
|
|
719
|
+
apiKey: process.env.E2B_API_KEY!,
|
|
720
|
+
size: "standard",
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
let workspace = await direct.workspaces.create({
|
|
725
|
+
repo: "https://github.com/acme/project",
|
|
726
|
+
lifecycle: "ephemeral",
|
|
727
|
+
models: {
|
|
728
|
+
providers: { anthropic: { source: "apiKey", apiKey: process.env.ANTHROPIC_API_KEY! } },
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
const run = await direct.runs.create({ workspace, prompt: "Review the open pull request" });
|
|
734
|
+
const completed = await direct.runs.result(run);
|
|
735
|
+
console.log(completed.finalText);
|
|
736
|
+
} finally {
|
|
737
|
+
workspace = await direct.workspaces.terminate(workspace);
|
|
738
|
+
}
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
`DirectWorkspace` and `DirectRun` are JSON-serializable. A direct run carries its workspace runtime
|
|
742
|
+
access, so `get`, `watch`, `events`, `wait`, `result`, `respond`, `messages`, and `cancel` take just
|
|
743
|
+
the run, exactly as in managed mode. Persist the complete run to reattach after a process restart;
|
|
744
|
+
continue a completed run with `context: { type: "continue", run: completed }`. After resuming a
|
|
745
|
+
workspace, update a persisted run's `workspace` with the returned workspace before reattaching.
|
|
746
|
+
Direct mode does not provide managed durable run storage or submission idempotency.
|
|
747
|
+
Serialized workspaces/runs contain runtime credentials: encrypt them and never send them to an
|
|
748
|
+
untrusted UI. Custom providers can implement `DirectProviderAdapter`; inspect `client.provider.capabilities`.
|
|
749
|
+
|
|
750
|
+
Both modes default to OpenCode **v1** and share v1/v2 run, permission, question, and SSE adapters.
|
|
751
|
+
For direct v2, supply a compatible provider image/template and `opencode: { api: "v2" }` at workspace
|
|
752
|
+
creation. The flag chooses the protocol; it does not install or upgrade the runtime. Direct mode
|
|
753
|
+
does not accept saved/dashboard credential sources or `inherit: "defaults"`.
|
|
754
|
+
|
|
755
|
+
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.
|
|
756
|
+
|
|
757
|
+
Direct setup has explicit phases. `beforeAgent` blocks workspace creation, while
|
|
758
|
+
`afterAgent` runs in the background and can be observed with `setupStatus()` or
|
|
759
|
+
`waitForSetup()`:
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
const workspace = await direct.workspaces.create({
|
|
763
|
+
repo: "https://github.com/acme/project",
|
|
764
|
+
setup: {
|
|
765
|
+
beforeAgent: ["npm install"],
|
|
766
|
+
afterAgent: ["npm run generate"],
|
|
767
|
+
},
|
|
768
|
+
secretFiles: [
|
|
769
|
+
{
|
|
770
|
+
path: "~/.config/gcloud/service-account.json",
|
|
771
|
+
content: process.env.GCP_SERVICE_ACCOUNT_JSON!,
|
|
772
|
+
mode: 0o600,
|
|
773
|
+
},
|
|
774
|
+
],
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
await direct.workspaces.waitForSetup(workspace);
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
To attach to an existing exe.dev VM without giving Gitterm ownership of that VM, pass
|
|
781
|
+
`exedev: { existingVmName: "acme-agent-machine" }` to `workspaces.create()`. Terminating
|
|
782
|
+
that workspace stops only its tracked agent process and does not remove the VM.
|
|
783
|
+
|
|
784
|
+
Trusted integration context can be appended to the generated global `AGENTS.md` without changing the model system prompt:
|
|
785
|
+
|
|
786
|
+
```ts
|
|
787
|
+
await direct.workspaces.create({
|
|
788
|
+
repo: "https://github.com/acme/project",
|
|
789
|
+
additionalAgentInstructions:
|
|
790
|
+
"You are running as a Slack bot. Keep responses concise and suitable for a thread.",
|
|
791
|
+
});
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### Provider authentication
|
|
795
|
+
|
|
796
|
+
Direct **v2** workspaces can start OpenCode provider authentication without shell access. This
|
|
797
|
+
connection-management API is v2-only; v1 supports inline API keys/OAuth bundles at creation and
|
|
798
|
+
`auth.setCredential()`. Discover a headless/device-code method for remote authentication:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
const openai = await direct.auth.get(workspace, "openai");
|
|
802
|
+
const method = openai.methods.find(
|
|
803
|
+
(item) => item.type === "oauth" && item.id === "chatgpt-headless",
|
|
804
|
+
);
|
|
805
|
+
if (!method || method.type !== "oauth") throw new Error("OpenAI device OAuth is unavailable");
|
|
806
|
+
|
|
807
|
+
const attempt = await direct.auth.connectOAuth({
|
|
808
|
+
workspace,
|
|
809
|
+
integrationId: "openai",
|
|
810
|
+
methodId: method.id,
|
|
811
|
+
label: "Slack bot",
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// Present these through your application UI.
|
|
815
|
+
console.log(attempt.url, attempt.instructions);
|
|
816
|
+
|
|
817
|
+
if (attempt.mode === "auto") {
|
|
818
|
+
await direct.auth.wait(attempt, workspace);
|
|
819
|
+
} else {
|
|
820
|
+
await direct.auth.complete(attempt, workspace, await getCodeFromUser());
|
|
821
|
+
}
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
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.
|
|
825
|
+
|
|
826
|
+
Applications that own OAuth separately can keep the token bundle in encrypted storage and inject it into every new workspace instead:
|
|
827
|
+
|
|
828
|
+
```ts
|
|
829
|
+
const credential = await credentialStore.get(slackInstallationId);
|
|
830
|
+
const workspace = await direct.workspaces.create({
|
|
831
|
+
lifecycle: "ephemeral",
|
|
832
|
+
models: {
|
|
833
|
+
providers: {
|
|
834
|
+
openai: {
|
|
835
|
+
source: "oauth",
|
|
836
|
+
refreshToken: credential.refreshToken,
|
|
837
|
+
accessToken: credential.accessToken,
|
|
838
|
+
expiresAt: credential.expiresAt,
|
|
839
|
+
accountId: credential.accountId,
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// v1 credentials can also be added or rotated on an existing runtime.
|
|
846
|
+
// On v2, setCredential supports API keys; use connectOAuth for OAuth rotation.
|
|
847
|
+
await direct.auth.setCredential(workspace, {
|
|
848
|
+
source: "oauth",
|
|
849
|
+
providerName: "openai",
|
|
850
|
+
refreshToken: credential.refreshToken,
|
|
851
|
+
accessToken: credential.accessToken,
|
|
852
|
+
expiresAt: credential.expiresAt,
|
|
853
|
+
});
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
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.
|
|
857
|
+
|
|
756
858
|
## License
|
|
757
859
|
|
|
758
860
|
MIT
|