Contribute

Extending One Connect

Add an agent runtime, a tracker, an API area, an agent tool or an owner-approved action, following the same seams the built-in pieces use.

Where things plug in#

To addDo this
An agent runtimeOne profile in packages/core/src/adapterProfiles.ts and one VendorAdapter in packages/daemon/src/adapters/agents/
A trackerOne TrackerAdapter in packages/daemon/src/adapters/trackers/, plus its config shape
An API areaserver/<area>Routes.ts exporting register<Area>Routes(app, d), listed in ROUTE_MODULES in http.ts
An acc MCP toolRegister it in mcp/server.ts, add its name to ACC_MCP_AUTO_APPROVED or ACC_MCP_PROMPTED in mcp/tools.ts (a test fails otherwise), and handle it in mcpBridge.ts
An action that needs the owner's approvalBind it with decisions.createBound(...), then add one entry to decisionExecutors.ts
Cleanup before a run stopsruns.onBeforeStop(hook) from the composition root
A serviceA class taking <Name>Deps, constructed in daemon.ts, added to start() and shutdown() if it has a lifecycle

Paths are under packages/daemon/src/ unless stated. Services import CommandError from errors.ts and types with import type; nothing but the delivery layer imports daemon.ts at runtime. Hash with util/hash.ts and canonicalize with canonicalJson from core, because approval bindings depend on both being identical everywhere. Validate request bodies with zod through parseBody in server/route.ts.

An agent adapter#

An agent runtime has two pieces.

Its profile, in packages/core/src/adapterProfiles.ts, holds the facts every package shares. The web reads it directly, so the setup page, login buttons and console hints need no edits. Add the name to ADAPTER_TYPES in config.ts too.

'my-cli': {
  displayName: 'My CLI',
  installUrl: 'https://vendor.example/cli',
  loginHint: 'Run `my login` in a terminal',
  loginArgs: ['login'],                  // null when the runtime has no login of its own
  runsHere: true,                        // false only for manual relay
  capabilities: { headless: true, streamingInput: false, resume: true, hostApprovals: false, mcp: true },
},

Its behaviour is one VendorAdapter (interface in adapters/agents/types.ts). Process supervision, durable logs, redaction, approvals, rules and the UI are shared, so the adapter only needs to know how to talk to its CLI.

export const myAdapter: VendorAdapter = {
  type: 'my-cli',
  ...ADAPTER_PROFILES['my-cli'],
  binaryNames: ['my'],
  knownLocations: () => ['/Applications/My.app/Contents/Resources/my'],  // app-bundled copies
  vendorHost: 'api.vendor.example',      // connectivity check before a retry

  authStatus(p) {},      // { state: logged_in | expired | not_installed | unknown, fix }
  billingMode(p) {},     // { mode: subscription | metered | unknown, source, overage }
  listModels(p) {},      // from the CLI's own catalog when it has one
  usage(p) {},           // included-usage windows, or unknownUsage(…): never guess
  buildLaunch(ctx) {},   // argv, cwd, stdin mode, files (e.g. MCP config); no secrets in argv
  createParser(ctx) {},  // stdout/stderr line → events, session, usage, final, failure, result
};

Register it in VENDOR_ADAPTERS (adapters/agents/index.ts). If the CLI takes a tool allowlist, pre-approve the acc tools with accToolPatterns() from mcp/tools.ts.

Rules for adapters:

  • Subscription first. billingMode must say metered whenever the tool would bill per token. Add the tool's API-key variables to METERED_ENV_VARS in env.ts so they are stripped from runs.
  • Report the session. Return { kind: 'session', apiKeySource } when the stream exposes it; a non-subscription source stops the run. Report plan usage with { kind: 'plan_usage', overage }.
  • Be MCP-aware when the CLI supports it: pass ctx.mcp, and keep the user's own MCP servers out where the CLI allows.
  • Make worktree commits work in sandboxes. Grant write access to ctx.gitCommonDir, where a worktree's git index lives.
  • Classify failures with classifyFailure (network, usage limit, auth, unsupported model, sandbox denied), and add vendor phrasing to failures.ts.
  • Never put credentials or secrets in argv or in files you write.

Contract tests:

  1. Run one tiny real session with the vendor's CLI. Sanitize it (session IDs, paths, account numbers) and commit it as packages/daemon/test/fixtures/real/<vendor>.jsonl.
  2. Add a case to packages/daemon/test/adapters.contract.test.ts. The shared assertions check the session, final answer, success, token usage and assistant message.
  3. Teach examples/fake-agent/fake-agent.mjs your output format and add a bin/<name> wrapper, so the end-to-end suite can run failure scenarios for free.

Runtimes that expose a local server instead of a CLI stream (like OpenCode) use the same contract: the detached runner starts a small bridge that owns the server, prints one JSON line per event, and routes permission and question requests back with the run token. adapters/agents/opencode.ts is the reference, tested against the loopback fixture examples/fake-agent/fake-opencode.mjs, never a live paid provider.

A tracker adapter#

Implement one interface, TrackerAdapter in adapters/trackers/types.ts:

interface TrackerAdapter {
  type: 'local' | 'github' | 'linear' | …;
  target: string;            // shown in the UI, e.g. "Jira project ENG"
  live: boolean;             // outbound adapters do nothing until the owner sets live: true
  init(): Promise<void>;
  createIssue(i: NewIssue): Promise<TrackerIssue>;
  getIssue(id: string): Promise<TrackerIssue | null>;
  listIssues(o?: { updatedSince?: string | null }): Promise<TrackerIssue[]>;
  setStatus(id: string, category: TrackerCategory, lifecycle: LifecycleState): Promise<void>;
  comment(id: string, body: string): Promise<void>;
}
  • Statuses. Map the neutral categories (backlog, todo, in_progress, in_review, done, canceled) to the tracker's states, and let users override names in config, as Linear's state_names does.
  • Acceptance criteria travel in the issue body as a ## Acceptance criteria checklist; use composeBody and splitBody.
  • Not live yet. When live is false, throw TrackerNotLiveError from every outbound call. The outbox keeps those writes pending and the UI shows dry run.
  • Credentials. Prefer the user's own signed-in CLI, as GitHub does with gh. Otherwise read a key at runtime from the Keychain or an environment variable and pass it to onSecret so it is redacted. Never store it.
  • Delivery. The outbox retries writes, so make setStatus and comment safe to repeat.

Wire it up by adding its config shape to TrackerConfigSchema in packages/core/src/config.ts and constructing it in createTracker (adapters/trackers/index.ts). Add a case to packages/daemon/test/trackers.contract.test.ts against a fake (a shim binary like fixtures/fake-gh.mjs, or a mocked fetch); the shared contract covers create, get, list, status changes and comments.

An API area#

// packages/daemon/src/server/widgetRoutes.ts
export function registerWidgetRoutes(app: FastifyInstance, d: Daemon) {
  app.get('/api/widgets', async () => d.widgets.list());
  app.post('/api/widgets', async (req) => d.widgets.create(parseBody(WidgetSchema, req.body)));
}

Add registerWidgetRoutes to ROUTE_MODULES in server/http.ts. The HTTP layer already enforces Host, Origin and authentication for every route in the list.

An owner-approved action#

An action that must wait for the owner is bound to a Decision. Create it with decisions.createBound(...), binding everything that must not change between review and execution, then add one entry to decisionExecutors.ts naming the key prefix it owns and how to resolve it. Plain answers to that Decision are then refused, and the owner's response is routed to your executor automatically, once.

Edit this page on GitHub