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 add | Do this |
|---|---|
| An agent runtime | One profile in packages/core/src/adapterProfiles.ts and one VendorAdapter in packages/daemon/src/adapters/agents/ |
| A tracker | One TrackerAdapter in packages/daemon/src/adapters/trackers/, plus its config shape |
| An API area | server/<area>Routes.ts exporting register<Area>Routes(app, d), listed in ROUTE_MODULES in http.ts |
An acc MCP tool | Register 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 approval | Bind it with decisions.createBound(...), then add one entry to decisionExecutors.ts |
| Cleanup before a run stops | runs.onBeforeStop(hook) from the composition root |
| A service | A 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.
billingModemust saymeteredwhenever the tool would bill per token. Add the tool's API-key variables toMETERED_ENV_VARSinenv.tsso 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 tofailures.ts. - Never put credentials or secrets in argv or in files you write.
Contract tests:
- 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. - 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. - Teach
examples/fake-agent/fake-agent.mjsyour output format and add abin/<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'sstate_namesdoes. - Acceptance criteria travel in the issue body as a
## Acceptance criteriachecklist; usecomposeBodyandsplitBody. - Not live yet. When
liveis false, throwTrackerNotLiveErrorfrom 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 toonSecretso it is redacted. Never store it. - Delivery. The outbox retries writes, so make
setStatusandcommentsafe 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.