@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 +588 -263
- package/dist/client.d.ts +39 -21
- package/dist/direct/client.d.ts +15 -14
- package/dist/direct/index.js +1377 -179
- package/dist/direct/runs.d.ts +19 -0
- package/dist/direct/types.d.ts +25 -23
- package/dist/errors.d.ts +9 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +375 -82
- package/dist/runs.d.ts +19 -0
- package/dist/types.d.ts +277 -23
- package/package.json +8 -4
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
|
-
##
|
|
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
|
-
|
|
9
|
+
```sh
|
|
10
|
+
bun add @gitterm/sdk
|
|
11
|
+
# or
|
|
12
|
+
npm install @gitterm/sdk
|
|
13
|
+
```
|
|
12
14
|
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
`
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
218
|
-
|
|
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,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
|
-
###
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
###
|
|
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.
|
|
393
|
-
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
554
|
+
workspace,
|
|
418
555
|
idempotencyKey: "onboarding-tests-v1",
|
|
419
556
|
prompt: "Now add tests for that change.",
|
|
420
|
-
context: { type: "continue",
|
|
557
|
+
context: { type: "continue", run: completed },
|
|
421
558
|
});
|
|
422
559
|
```
|
|
423
560
|
|
|
424
|
-
|
|
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
|
-
|
|
564
|
+
## Model provider credentials
|
|
427
565
|
|
|
428
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
- Unknown providers or inline keys for OAuth-only providers throw `MODEL_CREDENTIAL_INVALID
|
|
470
|
-
missing
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
629
|
+
## Errors
|
|
479
630
|
|
|
480
|
-
|
|
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.
|
|
638
|
+
await client.runs.create({ workspace: workspaceId, prompt });
|
|
487
639
|
} catch (error) {
|
|
488
|
-
if (error instanceof
|
|
489
|
-
|
|
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
|
|
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
|
|
497
|
-
`NOT_LOGGED_IN`, `UNAUTHORIZED`, `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`,
|
|
498
|
-
`SERVER_ERROR`, and `
|
|
650
|
+
`WORKSPACE_RESTART_FAILED`. Credential failures use the `MODEL_CREDENTIAL_*` codes listed above.
|
|
651
|
+
General codes are `NOT_LOGGED_IN`, `UNAUTHORIZED`, `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`,
|
|
652
|
+
`CONFLICT`, `SERVER_ERROR`, `NETWORK`, `TIMEOUT` (a `timeoutMs` elapsed), and `ABORTED` (a wait
|
|
653
|
+
was cancelled through its `signal`).
|
|
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
|
-
|
|
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
|