npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sdk

Requires 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 login

Tokens 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-up

Every 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              connected

Personal 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.token is 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 gh share 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:shared connection 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:

  1. The workspace is running. runs.create() waits for a pending workspace to transition on its own, up to startTimeoutMs (default 120s). It does not resume a paused workspace; call ensureRunning() for that, which resumes, polls until running, and returns the runtime access.
  2. The agent answers. running is 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.
  3. Setup finished (optional). afterAgent commands run in the background once the agent is reachable. Pass waitForSetup: true to runs.create() to block on them, or poll with setupStatus() / waitForSetup(). Status waiting means the agent isn't reachable yet, so setup hasn't started; running, succeeded, and failed describe the phase itself; not_requested means there were no afterAgent commands. The SDK polls setup from the client so a long npm ci never holds one HTTP request open; that polling needs the workspace:read scope on the token in addition to run:write. waiting cannot 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 setup failed with 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, and always. Reply { type: "permission", response: "once" | "always" | "reject" }.
  • A question request has questions[], each with a key, header, question, options[{ label, description }], multiple, and custom. Reply with the selected option labels keyed by question key, or one free-text string when custom is true: { type: "question", answers: { [question.key]: string[] } }. Every question needs an entry. Or dismiss all of them with { type: "question", reject: true }. custom defaults to true, matching OpenCode; false only when the agent sets it. Questions with options: [] accept free-text answers when custom is 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 models ships every saved dashboard account, labelled as in the dashboard, with each provider's default as the active OpenCode account. An explicit models block defaults to inherit: "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 throws MODEL_CREDENTIAL_UNAVAILABLE.
  • A run that requests a credential-backed provider/model not available in its workspace throws MODEL_CREDENTIAL_REQUIRED before 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