@gitterm/sdk
v0.11.0
Published
TypeScript SDK for the [GitTerm](https://gitterm.dev) API: create sandboxed workspaces for a repository, run an agent in them, and relay the questions it asks back to a human. Used by the `gitterm` CLI, the OpenCode plugin, and integrations such as Slack
Readme
@gitterm/sdk
TypeScript SDK for the GitTerm API: create sandboxed workspaces for a
repository, run an agent in them, and relay the questions it asks back to a human. Used by the
gitterm CLI, the OpenCode plugin, and integrations such as Slack bots.
Install
bun add @gitterm/sdk
# or
npm install @gitterm/sdkRequires Node 22.12+ or Bun. Create an API token under API tokens in the dashboard, or with
gitterm login.
Quick start
Create a workspace, run a prompt, and get the final result:
import { createGittermClient } from "@gitterm/sdk";
const client = createGittermClient({ token: process.env.GITTERM_API_TOKEN });
const { workspace } = await client.workspaces.create({
repo: "https://github.com/acme/product",
connections: ["github"], // the GitHub connection that covers acme
autoTerminateAfterMs: 2 * 60 * 60 * 1000,
});
try {
const run = await client.runs.create({
workspace,
idempotencyKey: "review-pr-42",
prompt: "Review PR #42 and fix the failing tests.",
// Optional files for the agent, e.g. a screenshot of the failure.
attachments: [{ name: "failure.png", mime: "image/png", data: base64Png }],
});
const result = await client.runs.result(run);
console.log(result.finalText);
} finally {
await client.workspaces.terminate(workspace);
}Every integration you can attach, personal or admin-provided, is a connection with one shape. Reference each one the way you remember it:
await client.workspaces.create({
repo: "https://github.com/acme/product",
connections: [
"github", // integration key: the GitHub connection that covers acme, or the shared one
"Linear", // a connection name, as shown under Integrations (case-insensitive)
"3f7d0c2e-…", // or an id from connections.list()
],
});An integration key works when it is unambiguous: github picks by the repository's owner;
google, mcp, and executor need exactly one connection of that kind. Names are not unique, so
a name shared by two connections is rejected with both ids; use the id or rename one. At most one
GitHub and one Google connection can be attached; MCP and Executor connections are unlimited.
GitHub tokens and Google workload credentials are issued inside the workspace; the SDK caller does not receive or forward them.
result() returns only on successful completion. If the agent asks something and no handler is
registered, it throws AgentRunError with code INPUT_REQUIRED and the current error.run.
Register onPermission / onQuestion handlers for interactive runs, or use the event relay below.
The quick start's finally deliberately terminates the workspace on any error; omit that cleanup
if your application needs to keep a blocked run available for a later answer.
askHuman is your side of an event relay: show the request, wait for an answer, and return an
AgentRunReply. This terminal example gives up after ten minutes:
import { createInterface } from "node:readline/promises";
import type { AgentRunInputRequest, AgentRunReply } from "@gitterm/sdk";
async function prompt(text: string, signal: AbortSignal): Promise<string | null> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return (await rl.question(text, { signal })).trim();
} catch {
return null; // the deadline passed
} finally {
rl.close();
}
}
async function askHuman(request: AgentRunInputRequest): Promise<AgentRunReply> {
const deadline = AbortSignal.timeout(10 * 60_000);
if (request.kind === "permission") {
// request.title, e.g. "bash: rm -rf dist"
const answer = await prompt(`Allow ${request.title}? [yes/always/no] `, deadline);
if (answer === null) return { type: "permission", response: "reject" };
return {
type: "permission",
response: answer === "always" ? "always" : answer === "yes" ? "once" : "reject",
};
}
const answers: Record<string, string[]> = {};
for (const question of request.questions) {
const labels = question.options.map((option) => option.label).join(" | ");
const answer = await prompt(`${question.header}: ${question.question} [${labels}] `, deadline);
if (answer === null) return { type: "question", reject: true }; // nobody answered in time
answers[question.key] = question.multiple ? answer.split(",").map((s) => s.trim()) : [answer];
}
return { type: "question", answers };
}Rejecting a permission or a question ends the agent's turn; the run then finishes as
completed or failed with what it managed to do. The rest of this document explains each
step in depth. Direct provider mode, which runs the same API against your own cloud account
without a GitTerm server, is described at the end.
Configuration
createGittermClient() resolves its server and token in this order: constructor options →
GITTERM_SERVER_URL / GITTERM_API_TOKEN → the CLI config at ~/.config/gitterm/cli.json
written by gitterm login. The default server is the hosted API at https://api.gitterm.dev;
self-hosted instances use the same SDK with their own serverUrl. Behind the bundled
path-routing proxy, point it at the /api path (for example
GITTERM_SERVER_URL=https://<host>/api); a server reached directly uses its origin:
const hosted = createGittermClient({ token: process.env.GITTERM_API_TOKEN });
const selfHosted = createGittermClient({
serverUrl: "https://gitterm.example.com/api", // or http://localhost:3000
token: process.env.GITTERM_API_TOKEN,
});
const fromCli = createGittermClient(); // env vars, then the CLI's saved loginTokens are the same gt_... shape on hosted and self-hosted. client.auth.status() and
client.serverUrl show which account and server you hit.
API
client.auth.status(); // -> { userId, email, name, plan, authMethod }
client.workspaces.list(options?); // -> { workspaces, pagination }; filter by status or metadata
client.workspaces.get(workspace); // workspace = id string or any object with an `id`
client.workspaces.getRuntimeAccess(workspace); // read-only; never resumes compute
client.workspaces.ensureRunning(workspace, options?); // resume if paused, wait until running
client.workspaces.setupStatus(workspace);
client.workspaces.waitForSetup(workspace, options?);
client.workspaces.pause(workspace); // -> { durationMinutes } of the usage session just closed
client.workspaces.restart(workspace);
client.workspaces.terminate(workspace); // -> { workspace, cleanupInBackground }
client.workspaces.create({ repo: "https://github.com/acme/product" });
client.runs.create(input);
client.runs.list(workspace, options?); // -> { runs, pagination }
client.runs.get(run); // run = AgentRun or any { workspaceId, id }
client.runs.messages(run);
client.runs.cancel(run);
client.runs.watch(run, options?); // AsyncIterable<AgentRun>: every lifecycle state until terminal
client.runs.wait(run, options?); // first state that is terminal or awaiting_input
client.runs.result(run, options?); // successful final result; optional input handlers
client.runs.events(run, options?); // actionable, subscriber-deduplicated input/lifecycle events
client.runs.respond(run, { requestId, reply }); // answer a permission prompt or agent question
client.catalog.agentTypes();
client.catalog.cloudProviders();
client.catalog.workspaceOptions();
client.credentials.list(); // dashboard credential metadata, never secrets
client.credentials.listProviders();
client.integrations.catalog(); // integrations the admin enabled: key, category, personal/shared
client.integrations.connections.list(filter?); // what you can attach; personal + shared, one shape
client.integrations.connections.get(id);
client.integrations.connections.resolve(references, { repo }); // what ids/keys/names attach
client.integrations.connections.create(input); // integrations:write; may return a browser step
client.integrations.connections.remove(id);
client.integrations.connections.waitFor({ integration, since }); // after a `pending` create
client.integrations.github.repositories(connectionId);
client.integrations.github.branches(connectionId, owner, repo);
client.integrations.google.setup(); // issuer + attribute mapping to configure Google
client.workspaces.models(workspace); // resolved credential sources; no secrets or runtime wake-upEvery poll-based wait (ensureRunning, waitForSetup, and runs.create with waitForSetup)
accepts { timeoutMs, pollIntervalMs, signal }. runs.watch is push-based (server-sent events)
and accepts { signal }; runs.wait adds { timeoutMs, until }. An AbortSignal stops any of
them with code ABORTED; an elapsed timeoutMs rejects with code TIMEOUT.
pause() returns durationMinutes, the length of the usage session it just closed, which is
what billing records. terminate() returns cleanupInBackground: true when the provider
finishes tearing down resources after the call returns.
Workspaces
Integrations and connections
client.integrations works the same way for every provider. catalog() tells you which
integrations the admin has enabled and whether each allows personal connections (you create
them, e.g. by installing the GitHub App) and/or a shared one (the admin provides it for the
whole deployment). connections.list() returns everything you can attach right now:
for (const c of await client.integrations.connections.list()) {
console.log(c.id, c.integration, c.kind, c.name, c.status);
}
// 8c1f… github personal acme-bot (GitHub App) connected
// github:shared github shared acme-ci (shared PAT) connected
// 3f7d… google personal Production connectedPersonal connection ids are stable row ids. Shared connection ids are well-known:
<integration>:shared. Pass any mix to workspaces.create({ connections: [...] }); the server
rejects more than one connection per integration. Credentials are issued inside the workspace and
are never returned to the SDK caller.
Creating connections from the SDK
Creating a connection requires an API token with the integrations:write scope. Some providers
complete immediately; others need a browser step and return pending:
// Google Cloud: completes immediately, then tell the user what to run in gcloud.
const setup = await client.integrations.google.setup(); // issuer to configure on the provider
const created = await client.integrations.connections.create({
integration: "google",
name: "Production",
projectId: "my-project",
workloadIdentityProvider:
"projects/123456789/locations/global/workloadIdentityPools/gitterm/providers/gitterm",
serviceAccountEmail: "[email protected]",
});
if (created.status === "connected") {
for (const step of created.nextSteps) console.log(step.label, "\n", step.command);
}
// GitHub App: the user installs the App in a browser, then we wait for the connection.
const started = new Date();
const pending = await client.integrations.connections.create({ integration: "github" });
if (pending.status === "pending") {
console.log("Open", pending.authorizeUrl);
const github = await client.integrations.connections.waitFor({
integration: "github",
since: started,
});
console.log("Connected as", github.name);
}connections.remove(id) disconnects a personal connection (for GitHub it asks GitHub to uninstall
the App; the removal completes when GitHub confirms). Shared connections are managed by the admin.
GitHub
A GitHub App connection can browse what it has access to:
const repos = await client.integrations.github.repositories(github.id);
const branches = await client.integrations.github.branches(github.id, "acme", "product");The admin picks one repository mode for the deployment: either users install a GitHub App
(personal connections) or the admin provides a shared PAT (github:shared). The shared PAT
authenticates as the admin-chosen GitHub account, so its permissions apply to every workspace that
attaches it. Neither mode requires GitHub login.
Managed workspaces can also use dashboard-managed model subscriptions while accepting an application-owned GitHub PAT inline:
const client = createGittermClient({
token: process.env.GITTERM_API_TOKEN,
});
const { workspace, runtime } = await client.workspaces.create({
repo: "https://github.com/acme/private-repo",
branch: "main",
repositoryCredentials: {
username: "x-access-token",
token: process.env.GITHUB_TOKEN!,
},
});The username defaults to x-access-token. Use either a GitHub connection in connections or
repositoryCredentials, not both. Both authenticate repository validation, cloning, and runtime
Git operations such as pull and push. Omitting models continues to use dashboard-managed model
credentials.
GitHub CLI authentication
For GitHub repositories, GitTerm configures both Git and gh from the same workspace
credentials. Agents can run commands such as gh pr list or gh pr create without
running gh auth login:
repositoryCredentials.tokenis retained in a permission-restricted file on the workspace machine and is not automatically renewed. This also works with the standalone SDK's direct providers.- A personal GitHub connection uses the GitHub App installation token. Git and
ghshare a cached token and refresh it through GitTerm before expiry. Concurrent commands share the refresh, and a failed refresh stops the command rather than using an expired token. - The shared
github:sharedconnection uses the admin's deployment-wide PAT. Switching the deployment's GitHub mode prevents future refreshes for existing workspaces using it; revoke the PAT at GitHub to stop access in an already-running workspace. - Without repository credentials or an integration, GitTerm leaves CLI authentication to the environment or the CLI's existing configuration.
Commit attribution is separate from these push credentials. By default the workspace owner is the author (their GitHub noreply address when GitHub is linked) and GitTerm is the committer: the GitHub App's bot account when the deployment uses an app. Settings → Account → Commit attribution makes the owner both instead, for new workspaces.
GitTerm supplies GH_TOKEN only to the invoked CLI process, so there is no stale
installation token exported into the agent's long-running environment. Explicit
GH_TOKEN or GITHUB_TOKEN environment variables override this CLI authentication;
they do not change the credentials used by Git. Managed credentials are for github.com.
The generated AGENTS.md tells agents that $HOME/.gitterm/bin/gh indicates GitTerm
authentication was provisioned. Agents install the real CLI separately when needed, keep
the GitTerm launcher first on PATH, and use the normal gh command. $GH_TOKEN is
intentionally not exported globally, and agents are instructed not to pipe it into
gh auth login.
CLI installation is left to the image or the agent as needed for the task; there is no SDK
installation option. The guidance explains checking gh --version and installing the CLI
if needed. The launcher discovers the
actual binary on each invocation, including installations made after startup.
It lives in ~/.gitterm/bin, which GitTerm adds to the agent's PATH and standard shell
profiles. Install the actual CLI elsewhere and keep ~/.gitterm/bin first on PATH so it
does not overwrite or bypass the launcher. Node.js and Git are required by the
authentication runtime.
Available commands depend on the token's repository access and permissions. Installation tokens act as the GitHub App bot; user-scoped commands may require user authentication. Updating an integration's token does not grant permissions absent from its installation.
GitTerm does not save inline PATs in its application database. Inline PATs must be delivered to the
selected compute provider and retained on the workspace machine for runtime Git operations, so
provider infrastructure and processes running in that workspace may be able to access them. Prefer
a GitHub connection for durable managed workspaces and use narrowly scoped, short-lived PATs when
inline credentials are necessary. The standalone/direct SDK has no GitTerm account to look up:
it supports repositoryCredentials but not connections.
The SDK deliberately exposes two clients. createGittermClient() uses a user API token and
can manage the user's workspaces. createGittermWorkspaceClient() uses the scoped identity
injected into a GitTerm workspace and can inspect only that workspace and its ports:
import { createGittermWorkspaceClient } from "@gitterm/sdk";
const workspace = createGittermWorkspaceClient();
const self = await workspace.self.get();
const preview = await workspace.ports.open(3000, { name: "app" });
const api = await workspace.ports.open(8080, { name: "api", visibility: "public" });Ports are private by default: only the workspace owner's signed-in GitTerm browser session can
reach the URL. Make a port public when it needs to be reachable without a GitTerm login, for
example an API or webhook receiver. Use workspace.ports.setVisibility(port, visibility) to change
it later.
The workspace client never reads the CLI's saved account login and has no create, list, pause, restart, or terminate operations.
Tagging and lifetime
metadata attaches caller-owned tags to a workspace so you can find it again without a
lookup table of your own, and autoTerminateAfterMs caps how long it can live:
await client.workspaces.create({
repo: "https://github.com/acme/product",
metadata: { tenant: "acme", channel: "C0123" },
autoTerminateAfterMs: 2 * 60 * 60 * 1000, // gone in 2h even if this process crashes
});
const { workspaces } = await client.workspaces.list({
metadata: { tenant: "acme", channel: "C0123" },
});Metadata allows up to 20 keys of letters, digits, _ . : -, with values up to 500 characters,
and list matches workspaces containing every given pair. autoTerminateAfterMs ranges from
1 minute to 30 days; the reaper terminates the workspace once the time passes regardless of
activity, and workspace.autoTerminateAt shows the deadline. Idle workspaces are still paused
by the platform's idle policy independently of this.
Bring your own image
Pass image to run your own build instead of the catalog image. On the managed service the
image must be pullable without credentials; creation fails immediately with the reason if it
isn't, rather than leaving a workspace stuck pulling.
await client.workspaces.create({
repo: "https://github.com/acme/product",
provider: { type: "railway" },
image: "ghcr.io/acme/agent-runner:1.4.0",
});
// E2B takes a public template id or alias instead of a registry reference.
await client.workspaces.create({
repo: "https://github.com/acme/product",
provider: { type: "e2b" },
image: "acme-python-runner",
});Supported on Railway, AWS, Daytona, exe.dev, and E2B. Vercel and Cloudflare run fixed
runtimes and reject image. Floating tags such as latest are pinned to the digest verified
at create time, so a restart months later runs the same image; workspace.customImage shows
what was pinned.
The image has to behave like the stock one, because GitTerm's entrypoint does the clone, writes agent files, runs setup phases, and starts the agent. Layer on the published base and keep its entrypoint:
FROM opeoginni/gitterm-opencode-server:latest
RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-pip poppler-utils \
&& rm -rf /var/lib/apt/lists/*Rules: do not override ENTRYPOINT or CMD; keep global installs outside /workspace, which
is a persisted volume; keep opencode and @gitterm/cli on PATH; keep port 7681. For E2B,
build your template from the same base and publish it so any account can start it.
The server defaults the agent to opencode, selects the user's preferred provider,
uses that provider's default machine profile, and applies the provider's persistence policy.
Override only the placement decisions your integration cares about:
await client.workspaces.create({
repo: "https://github.com/acme/product",
agent: "opencode",
setup: {
beforeAgent: ["npm install"],
afterAgent: ["npm run generate"],
},
opencode: {
skills: [
{
name: "release-demo",
content: `---
name: release-demo
description: Record and publish a product release demo.
---
Follow the repository's release-demo workflow.`,
},
],
plugins: ["@acme/[email protected]"],
},
provider: {
type: "exedev",
machine: { type: "profile", key: "content-rendering" },
},
});Setup commands run in order from the checked-out repository. beforeAgent blocks agent
startup; when it fails, create() rejects with the tail of its log. afterAgent starts
after the agent is reachable and reports status independently. Provider and agent defaults
configured by an administrator run first. Use client.workspaces.setupStatus(workspaceId)
or waitForSetup(workspaceId) to inspect the afterAgent phase. GitTerm persists bounded
logs and a recovery copy in the repository's git-excluded .gitterm/setup/ directory.
Setup commands can reference the checkout with $WORKSPACE_REPO_DIR, which is the same on
every provider even though the underlying path differs.
Secret files are created relative to the repository with restrictive permissions and are
added to .git/info/exclude so the agent cannot commit them. GitTerm does not retain their
contents; to rotate a secret, recreate the workspace. Like model credentials, they are
delivered to the sandbox through its launch environment, so anyone who can read the
provider's task or container definition can read them:
await client.workspaces.create({
repo: "https://github.com/acme/product",
secretFiles: [
{
path: ".secrets/gcp.json",
content: process.env.GCP_SERVICE_ACCOUNT_JSON!,
mode: "0600",
},
],
setup: {
beforeAgent: [
'gcloud auth activate-service-account --key-file "$WORKSPACE_REPO_DIR/.secrets/gcp.json"',
],
},
});provider is a discriminated union, so TypeScript only offers region for providers
where GitTerm supports caller-selected placement. Machine keys are configured by admins
and returned by client.catalog.workspaceOptions() with their vcpus and memoryGb;
raw CPU, memory, credentials, and provider account configuration are never supplied by SDK
callers. The catalog lists only the sizes you may use: an admin can pin a provider to its
default size, and on managed GitTerm the Free plan gets each provider's smallest size.
This makes release automation a normal workspace task: create an OpenCode workspace,
run UI review or browser capture tools in the sandbox, upload the resulting media, update
the changelog in the checked-out repository, then terminate the workspace. Use an
idempotencyKey based on the release SHA when the workflow may be retried.
Workspace lifecycle and readiness
A workspace has one of four statuses:
| Status | Meaning |
| ------------ | ----------------------------------------------------------------------------------------- |
| pending | Compute is being provisioned or resumed. runtime.url is null. |
| running | The provider reports the sandbox or container up. The agent process may still be booting. |
| paused | Stopped but resumable. |
| terminated | Gone for good. |
Whether create() returns running or pending depends on the provider. Sandbox providers
(E2B, Daytona, Vercel, boat, exe.dev, Cloudflare) settle immediately and return running.
Railway settles by webhook and returns pending until its deployment reports success, usually
within a minute. Code that only ever ran against a sandbox provider will see pending for the
first time when it moves to Railway.
Three things have to be true before a prompt can be delivered, and the SDK handles each:
- The workspace is
running.runs.create()waits for apendingworkspace to transition on its own, up tostartTimeoutMs(default 120s). It does not resume apausedworkspace; callensureRunning()for that, which resumes, polls untilrunning, and returns the runtime access. - The agent answers.
runningis the provider's view.runs.create()then probes the workspace URL until the agent server responds before submitting the prompt, so callers never need their own health check. - Setup finished (optional).
afterAgentcommands run in the background once the agent is reachable. PasswaitForSetup: truetoruns.create()to block on them, or poll withsetupStatus()/waitForSetup(). Statuswaitingmeans the agent isn't reachable yet, so setup hasn't started;running,succeeded, andfaileddescribe the phase itself;not_requestedmeans there were noafterAgentcommands. The SDK polls setup from the client so a longnpm cinever holds one HTTP request open; that polling needs theworkspace:readscope on the token in addition torun:write.waitingcannot last forever: while you poll, the server also reads the setup state files inside the workspace (through the agent when the provider has no exec channel), and if nothing has progressed after 15 minutes it marks the setupfailedwith a log explaining what stalled.
The shortest correct sequence is therefore:
const { workspace } = await client.workspaces.create({
repo,
setup: { afterAgent: ["npm ci"] },
});
const run = await client.runs.create({
workspace,
idempotencyKey: `review-${sha}`,
waitForSetup: true,
prompt: "Review the open pull request",
});
const done = await client.runs.result(run);For a workspace you didn't just create, start with ensureRunning():
const { workspace, runtime } = await client.workspaces.ensureRunning(workspaceId);
// runtime.url is set; workspace.status is "running"Failures surface as WorkspaceLifecycleError with stable codes: WORKSPACE_NOT_RUNNING (the
workspace is paused, or stopped while waiting), WORKSPACE_TERMINATED,
WORKSPACE_START_TIMEOUT (still pending after the timeout, or no runtime URL),
WORKSPACE_NON_RECOVERABLE, and WORKSPACE_RESTART_FAILED.
Direct provider mode behaves differently: direct.workspaces.create() waits for the OpenCode
runtime to answer before returning, so a direct workspace is ready for runs.create() as soon
as create() resolves.
Agent runs
Runs use durable GitTerm IDs backed by the workspace's native OpenCode session. Reusing an
idempotency key with the same input returns the original run, and terminal results remain
available after the workspace is paused. idempotencyKey defaults to a random UUID; pass your
own whenever a retry could otherwise submit the same prompt twice. Completion means the native
session became idle; it does not claim that a pull request, upload, or other product outcome
succeeded.
Every run carries createdAt, submittedAt (when the prompt reached the agent), and
completedAt. runs.list(workspace, { status: "active" }) returns what is still in flight, so a
process recovering after a restart can pick up where it left off without having persisted run
ids itself. The server watches every active run's OpenCode session and keeps the run current;
get/list/messages read that state without touching the workspace.
Statuses: pending → running (or retrying while OpenCode backs off from the model
provider) → completed | failed | cancelled. A run that stops to ask something is
awaiting_input until you answer it (below).
Following a run
For the common interactive case, let the SDK drive the loop:
const result = await client.runs.result(run, {
timeoutMs: 30 * 60_000,
onPermission: async (request, { signal }) => {
// Your UI returns "once", "always", or "reject". Never approve implicitly.
return await askForApproval(request, signal);
},
onQuestion: async (request, { signal }) => {
// Return { answers: { [question.key]: [selectedLabel] } } or { reject: true }.
return await collectAnswers(request, signal);
},
});
console.log(result.finalText);The total deadline includes handler time. A timeout or abort stops observation, not the agent;
call runs.cancel(run) to stop it. Handlers receive a signal to close their own UI. Handlers are
not invoked twice for the same request during one result() call. Failed/cancelled runs throw
AgentRunError with RUN_FAILED / RUN_CANCELLED, retaining the run snapshot on error.run.
For a bot or queue-based relay, use actionable events instead of processing every state snapshot:
for await (const event of client.runs.events(run)) {
if (event.type === "input.required") {
await client.runs.respond(run, {
requestId: event.request.id,
reply: await askHuman(event.request),
});
}
}For a deferred reply, publish the request to your UI/queue instead, persist the run reference and
request ID, and call respond() from the later handler. Event types are run.status,
input.required, input.resolved, run.completed, run.failed, and run.cancelled.
Deduplication is per iterator, not durable exactly-once delivery: a new subscriber receives any
still-pending input again. Persist (run.id, request.id) as your UI/queue deduplication key.
INPUT_NOT_PENDING means a stale or already-resolved request; refresh the run rather than retrying
the answer blindly. The API does not claim that a repeated answer is idempotent.
runs.watch(run) is an async iterable of the run's lifecycle states. It starts with the current
state, yields a new AgentRun whenever the status or the pending inputs change, and ends after
the terminal state. It is push-based (server-sent events), so there is nothing to poll:
for await (const state of client.runs.watch(run, { signal })) {
console.log(state.status);
}runs.wait(run, options?) is a helper over watch() that resolves with the first state that
needs attention: a terminal state, or awaiting_input unless you pass until: "terminal".
It takes timeoutMs (default 30 minutes; rejects with code TIMEOUT) and signal (rejects
with ABORTED). Use wait() when you want a separate timeout per phase, as a chat bot that
gives the agent twenty minutes per turn but a human thirty minutes per question would;
otherwise watch() with one AbortSignal is simpler.
Questions and permission prompts
When the agent calls OpenCode's question tool, or a tool needs approval under your
permission config, the run becomes awaiting_input and run.pendingInputs lists what it is
waiting for. There is usually one request; there are several when the agent issued parallel tool
calls that each need approval. Answer each with runs.respond(); the run resumes once none
remain. The exported types are AgentPermissionRequest, AgentQuestionRequest, and
AgentQuestion.
- A permission request has
title(e.g.bash: rm -rf dist),permission,patterns, andalways. Reply{ type: "permission", response: "once" | "always" | "reject" }. - A question request has
questions[], each with akey,header,question,options[{ label, description }],multiple, andcustom. Reply with the selected option labels keyed by questionkey, or one free-text string whencustomis true:{ type: "question", answers: { [question.key]: string[] } }. Every question needs an entry. Or dismiss all of them with{ type: "question", reject: true }.customdefaults to true, matching OpenCode; false only when the agent sets it. Questions withoptions: []accept free-text answers whencustomis true.
Question options are normalised at the runtime boundary: labels and descriptions are trimmed,
OpenCode's copied "Type your own answer" entry is removed and enables custom: true, and
duplicate and blank labels are dropped. Hosted users need the API redeployed for this to take effect.
The quick start above shows a complete relay. Rejecting a permission or dismissing a question
ends the turn: OpenCode records a failed tool call and the run finishes. A run left
awaiting_input does not keep its workspace awake; if nobody answers before the workspace's
idle timeout pauses it, the run is cancelled with "Workspace paused while waiting for input".
To avoid prompts entirely in headless runs, allow the relevant tools in
opencode.config.permission when creating the workspace.
Transcript
runs.messages() returns each turn's concatenated text plus an ordered parts array. Tool
calls appear as { type: "tool", tool, status, title, input, output, error } with output
truncated to 4000 characters, which is usually enough to see why a run went wrong:
for (const message of await client.runs.messages(run)) {
for (const part of message.parts) {
if (part.type === "tool" && part.status === "error") {
console.log(part.tool, part.title, part.error);
}
}
}The lifecycle stream does not mirror OpenCode's native message, tool, or token events. For live
execution details, use workspaces.getRuntimeAccess() with the official OpenCode SDK.
Continuing context
Runs are isolated by default and can execute in parallel. To preserve conversational context, continue a terminal run; continued runs sharing context must remain sequential:
const next = await client.runs.create({
workspace,
idempotencyKey: "onboarding-tests-v1",
prompt: "Now add tests for that change.",
context: { type: "continue", run: completed },
});runs.cancel(run) aborts the current run; a run that is awaiting_input has its pending
prompt rejected first. GitTerm keeps the underlying OpenCode session private.
Model provider credentials
Model selection and credential selection are separate decisions, grouped under models:
const { workspace } = await client.workspaces.create({
repo: "https://github.com/acme/product",
models: {
default: "openai/<model-id>",
inherit: "none", // only the providers listed here receive credentials
providers: {
openai: { source: "saved", label: "work" },
anthropic: { source: "default" },
google: { source: "apiKey", apiKey: process.env.GOOGLE_API_KEY! },
},
},
});
const run = await client.runs.create({
workspace,
model: "anthropic/claude-sonnet-5-5",
prompt: "Record before/after videos of the changes in PR #42",
});Saved credentials use labels, not IDs. Choose { source: "saved", label: "work" } or
{ source: "default" }. Provider keys are logical model providers: use openai for both OpenAI
API keys and saved ChatGPT subscriptions, not openai-oauth. credentials.list() returns labels
and logicalProviderKey, never secrets. If an API key and subscription share the same label,
selection fails explicitly; give them distinct dashboard labels. Discovery requires workspace:write.
Inline credentials use { source: "apiKey", apiKey } for this workspace only. A missing or
blank key fails validation; it never falls back to a saved credential. Keys are injected into
the sandbox, not saved in the dashboard. credentials.listProviders() includes each authentication
integration's logicalProviderKey. Managed OAuth credentials must be connected through the
dashboard.
workspaces.models(workspace) shows which credential sources were configured.
It reads control-plane metadata only: it does not verify a key with the model provider, discover
custom runtime models, or resume a paused workspace. A saved credential's label/active status is
its current dashboard metadata.
Rules and errors:
- Omitting
modelsships every saved dashboard account, labelled as in the dashboard, with each provider's default as the active OpenCode account. An explicitmodelsblock defaults toinherit: "none";models: {}injects no dashboard credentials. - Use
inherit: "defaults"to ship every unlisted provider's saved accounts the same way. Explicit sources override only their own provider and ship exactly one account for it. - Unknown providers or inline keys for OAuth-only providers throw
MODEL_CREDENTIAL_INVALID. A missing or ambiguous label throwsMODEL_CREDENTIAL_UNAVAILABLE. - A run that requests a credential-backed
provider/modelnot available in its workspace throwsMODEL_CREDENTIAL_REQUIREDbefore the prompt is submitted.
models.default sets the workspace's agent default; runs.create({ model }) overrides the model
for that run. Credentials remain workspace-scoped: changing credentials for one concurrent run
would otherwise change them for other runs on the same runtime.
OpenCode runtime
Every workspace runs OpenCode 2 (the @opencode/cli npm package) and its /api/* HTTP API. Runs,
questions, permissions, and events use GitTerm's OpenCode 2 runtime adapter. Attach a local TUI with
OPENCODE_PASSWORD=<password> opencode --server <workspace url>.
OpenCode 2 keeps credentials in its SQLite store and has no headless import, so workspaces receive
~/.gitterm/opencode/credentials.json and a local OpenCode plugin at
~/.config/opencode/plugins/gitterm-credentials.js. The plugin imports each account through OpenCode's
integration API on first load under its dashboard label (inline keys use Gitterm), so a provider can
carry several accounts and opencode auth switch works inside the workspace. The dashboard default is
the account OpenCode selects. Accounts whose label already exists on the integration are skipped, so
restarts are idempotent. OAuth entries are stored with OpenCode's built-in method IDs and refreshed by
OpenCode itself.
Errors
Managed methods and the shared run APIs throw GittermError with a stable code; never match on messages.
Direct provider lifecycle/authentication methods may also propagate provider errors.
import { GittermError, WorkspaceLifecycleError } from "@gitterm/sdk";
try {
await client.runs.create({ workspace: workspaceId, prompt });
} catch (error) {
if (error instanceof WorkspaceLifecycleError && error.code === "WORKSPACE_NOT_RUNNING") {
await client.workspaces.ensureRunning(workspaceId); // paused; resume and retry
} else if (error instanceof GittermError && error.code === "TIMEOUT") {
// the wait elapsed; the run itself may still be going
} else throw error;
}Workspace lifecycle failures are WorkspaceLifecycleError with codes WORKSPACE_NOT_RUNNING,
WORKSPACE_TERMINATED, WORKSPACE_NON_RECOVERABLE, WORKSPACE_START_TIMEOUT, and
WORKSPACE_RESTART_FAILED. Credential failures use the MODEL_CREDENTIAL_* codes listed above.
General codes are NOT_LOGGED_IN, UNAUTHORIZED, NOT_FOUND, FORBIDDEN, BAD_REQUEST,
CONFLICT, SERVER_ERROR, NETWORK, TIMEOUT (a timeoutMs elapsed), and ABORTED (a wait
was cancelled through its signal).
The package ships self-contained declarations from dist; TypeScript consumers do not
need GitTerm's API package or tRPC server types.
Versioning
@gitterm/sdk follows semver. Before 1.0, a minor bump (0.1 → 0.2) may contain breaking
changes and lists them in CHANGELOG.md with a migration note; patch releases
never change public types or behaviour. Pin ~0.2.0 if you want patches only.
Obtaining a token programmatically
The device-code flow used by gitterm login is exposed for integrations. Pass the
server URL of the instance you want to log into:
import { loginWithDeviceCode, saveConfig, DEFAULT_GITTERM_SERVER_URL } from "@gitterm/sdk";
// Hosted: DEFAULT_GITTERM_SERVER_URL ("https://api.gitterm.dev")
// Self-hosted: "https://gitterm.example.com" or "http://localhost:3000"
const serverUrl = process.env.GITTERM_SERVER_URL ?? DEFAULT_GITTERM_SERVER_URL;
const { token } = await loginWithDeviceCode(serverUrl, {
onCode: ({ verificationUri, userCode }) => {
console.log(`Visit ${verificationUri} and enter ${userCode}`);
},
});
await saveConfig({
serverUrl,
token,
createdAt: Date.now(),
});Device-code logins produce the same revocable gt_... API token as the dashboard;
they appear under API tokens in the dashboard and can be revoked there.
Direct provider mode
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.
All built-in compute providers use the same provisioning plan and workspace/run API:
| Provider | Direct prerequisite | Persistent pause | Keep-alive |
| -------- | -------------------------------------------------------------- | ---------------- | ---------- |
| E2B | OpenCode-compatible template | Yes | Yes |
| Daytona | Public Gitterm OpenCode server image by default | Yes | Yes |
| Vercel | Vercel Sandbox project | Yes | Yes |
| boat | boat API key (provider type ascii) | Yes | Yes |
| exe.dev | Lifecycle token, or an existing VM with ls,ssh,share,ssh-key | Yes | No |
| Railway | Project/environment and public service domains | With a volume | No |
AWS remains available through createGittermClient() and the Gitterm control plane; it is intentionally not exposed in direct mode.
Cloudflare remains available through the Gitterm control plane. Direct Cloudflare support is deferred until the OpenCode v2 Workerd runtime is stable.
import { createDirectGittermClient } from "@gitterm/sdk/direct";
const direct = createDirectGittermClient({
provider: {
type: "e2b",
apiKey: process.env.E2B_API_KEY!,
size: "standard",
},
});
let workspace = await direct.workspaces.create({
repo: "https://github.com/acme/project",
lifecycle: "ephemeral",
models: {
providers: {
anthropic: { source: "apiKey", apiKey: process.env.ANTHROPIC_API_KEY! },
},
},
});
try {
const run = await direct.runs.create({
workspace,
prompt: "Review the open pull request",
});
const completed = await direct.runs.result(run);
console.log(completed.finalText);
} finally {
workspace = await direct.workspaces.terminate(workspace);
}DirectWorkspace and DirectRun are JSON-serializable. A direct run carries its workspace runtime
access, so get, watch, events, wait, result, respond, messages, and cancel take just
the run, exactly as in managed mode. Persist the complete run to reattach after a process restart;
continue a completed run with context: { type: "continue", run: completed }. After resuming a
workspace, update a persisted run's workspace with the returned workspace before reattaching.
Direct mode does not provide managed durable run storage or submission idempotency.
Serialized workspaces/runs contain runtime credentials: encrypt them and never send them to an
untrusted UI. Custom providers can implement DirectProviderAdapter; inspect client.provider.capabilities.
Both modes run OpenCode 2 and share its run, permission, question, and SSE adapters. Provider
images/templates must ship OpenCode 2 (@opencode/cli). Direct mode does not accept saved/dashboard
credential sources or inherit: "defaults".
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.
Direct setup has explicit phases. beforeAgent blocks workspace creation, while
afterAgent runs in the background and can be observed with setupStatus() or
waitForSetup():
const workspace = await direct.workspaces.create({
repo: "https://github.com/acme/project",
setup: {
beforeAgent: ["npm install"],
afterAgent: ["npm run generate"],
},
secretFiles: [
{
path: "~/.config/gcloud/service-account.json",
content: process.env.GCP_SERVICE_ACCOUNT_JSON!,
mode: 0o600,
},
],
});
await direct.workspaces.waitForSetup(workspace);To attach to an existing exe.dev VM without giving Gitterm ownership of that VM, pass
exedev: { existingVmName: "acme-agent-machine" } to workspaces.create(). Terminating
that workspace stops only its tracked agent process and does not remove the VM.
Trusted integration context can be appended to the generated global AGENTS.md without changing the model system prompt:
await direct.workspaces.create({
repo: "https://github.com/acme/project",
additionalAgentInstructions:
"You are running as a Slack bot. Keep responses concise and suitable for a thread.",
});Provider authentication
Direct workspaces can start OpenCode provider authentication without shell access. Inline API keys/OAuth bundles are still accepted at creation. Discover a headless/device-code method for remote authentication:
const openai = await direct.auth.get(workspace, "openai");
const method = openai.methods.find(
(item) => item.type === "oauth" && item.id === "chatgpt-headless",
);
if (!method || method.type !== "oauth") throw new Error("OpenAI device OAuth is unavailable");
const attempt = await direct.auth.connectOAuth({
workspace,
integrationId: "openai",
methodId: method.id,
label: "Slack bot",
});
// Present these through your application UI.
console.log(attempt.url, attempt.instructions);
if (attempt.mode === "auto") {
await direct.auth.wait(attempt, workspace);
} else {
await direct.auth.complete(attempt, workspace, await getCodeFromUser());
}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.
Applications that own OAuth separately can keep the token bundle in encrypted storage and inject it into every new workspace instead:
const credential = await credentialStore.get(slackInstallationId);
const workspace = await direct.workspaces.create({
lifecycle: "ephemeral",
models: {
providers: {
openai: {
source: "oauth",
refreshToken: credential.refreshToken,
accessToken: credential.accessToken,
expiresAt: credential.expiresAt,
accountId: credential.accountId,
},
},
},
});
// API keys can also be added or rotated on an existing runtime; use connectOAuth for OAuth rotation.
await direct.auth.setCredential(workspace, {
source: "apiKey",
providerName: "openai",
apiKey: credential.apiKey,
});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.
License
MIT
