Authoring a connector
Reference: describes the TypeScript reference implementation, not the wire contract. · For: integrators adding a new agent harness · Wire contract: SPEC
A connector teaches Cotal how to launch one agent harness (Claude Code, OpenCode, your own) as a
mesh node. Connectors are ordinary extensions: you publish an npm package, the operator
runs cotal ext add <your-package>, and it plugs in the same way as the first-party connectors,
which are themselves just connectors seeded on first run. There is no special-casing for built-ins,
so anything the built-ins can do, yours can too.
The contract
Section titled “The contract”Implement Connector from @cotal-ai/core and self-register it on import:
import { registry, type Connector } from "@cotal-ai/core";
const myConnector: Connector = { kind: "connector", name: "myagent", // the --agent value; must be unique, never "cotal" requires: ["myagent"], // external CLIs the launch needs on PATH (preflighted) buildLaunch(opts) { // opts → the process + env that joins the mesh return { command: "myagent", args: ["--serve"], env: { /* COTAL_* wiring from opts */ }, }; }, // optional: listModels, supportsModelVariant, supportsPrompt, supportsResume, // supportsSessionContinuation, supportsSessionReopen, supportsToolListAnnounce, eventChannel, pluginRoot};
registry.register(myConnector); // registration runs on import, making the connector availablebuildLaunch(opts) is the whole job: given a LaunchOpts (space, name, role, creds, channels,
model, prompt…), return a LaunchSpec (the command, args, and environment) whose process connects to
the broker as that mesh node. model and variant arrive resolved: the launcher has already applied
the flag over the agent file’s model: and variant:, refused a whitespace-only value, and checked and recorded the result. Render
them as given and read the agent file only for the persona. A buildLaunch that throws refuses the
spawn, and the caller sees the thrown value’s message, or the value itself as text when it has
none. Everything else on the interface is optional and default-deny: declare
supportsModelVariant/supportsPrompt/supportsResume/supportsSessionContinuation/supportsSessionReopen/supportsToolListAnnounce only if you honor them (a request for one you don’t declare
fails loud before any provisioning), list requires so a missing CLI fails with a clear message, and
implement listModels only if you want a selector catalog. Implement eventChannel only if your
session publishes a structured event plane: it names the channel the manager grants that session
publish rights on, so the grant and the subject the session publishes to come from one function
rather than two that can drift. Event planes are on by default when this method exists. A connector
that omits it refuses a launch unless the caller explicitly opts out with --no-events or
events: false. See
the Connector interface in
packages/core/src/connector.ts and the OpenCode connector in
extensions/connector-opencode/ for a complete worked example.
Private launch files
Section titled “Private launch files”Text the child should not see in argv, such as a persona or an MCP config that names shared servers,
goes in a private file written with writeLaunchArtifact from @cotal-ai/core, with only its path
passed to the child. Pass the same artifacts array to every call and return it on the LaunchSpec.
The launcher owns those files: the manager and the foreground cotal spawn remove them once they
have proved the child gone, so the child may read them at any point in its life. A launch refused
before any process started removes them at once. Each directory name carries a random per-launch
identity, so a stale path can never name a later launch’s directory. Every runtime the manager
creates, the default pty included, and the foreground cotal spawn start the child through
reclaimWithChild from @cotal-ai/core: a POSIX shell starts a watcher and then runs the child in
its own place, and the watcher removes the files once the child’s process is gone, even when the
launcher was killed. The watcher tries a failed removal again every five seconds until it succeeds.
The pty runtime spawns the child in-process, and the manager removes the files on the exit it
streams. On a runtime that cannot stream an exit (tmux, cmux, orca, herdr) the manager also polls
the seat’s status and waits for the runtime’s exit proof. A failed removal is tried again until it
succeeds. A runtime that fails before it has handed
the child’s command to its backend, because it refused the launch or could not write its own launch
script, throws SpawnRefused from @cotal-ai/core, and the manager removes the files at once.
Any other spawn that throws is not proof, so its files stay for the child’s watcher, or for
the OS temp reaper when no child started. Windows has no POSIX shell for the watcher, so there a
killed launcher’s files also stay for the OS temp reaper. A seat started under a custodian with
launchSeat from @cotal-ai/seat, which the manager no longer does for a new launch, hands them to
that custodian instead: it removes them when it sees the child exit, and if it cannot, or is killed
first, the reap that proves the seat gone removes them from the temp dir the launch wrote them to.
Run every check that can refuse the launch, and every conversion that can throw, before the
first write. The file
is 0600 in a 0700 directory, which is OS-user isolation: any process running as the same user can
read it while it exists.
Local listeners
Section titled “Local listeners”A connector that carries the cotal_* surface over any local listener, loopback TCP or a Unix
socket, authenticates every connection with a secret the child receives through the launch
material or an environment variable and never through argv. It compares the presented secret to
its own in constant time, bounds the request body and the pre-authentication frame, and drops an
unauthenticated connection before it can reach a tool. State the same-uid limit rather than
claiming it away: a bind address is not a boundary on a shared workstation, only the secret is.
Copy one of the two shipped shapes rather than inventing a third: the Codex loopback MCP endpoint
(extensions/connector-codex/src/mcp.ts) or connector-core’s control server
(extensions/connector-core/src/control.ts, exported for reuse by a sibling listener in the same
process).
Packaging rules (enforced at ext add)
Section titled “Packaging rules (enforced at ext add)”cotal ext add verifies these and fails loud otherwise, because they are what keep every extension
sharing the binary’s single @cotal-ai/core registry instance:
@cotal-ai/coreis apeerDependency, never a regular dependency. A regular dep vendors a second copy of core. Its separate registry would swallow yourregistry.registercall. The add would import your package cleanly, see zero contributions, and refuse it. Any other@cotal-ai/*you use is a peer too.ext addjunction-links each@cotal-ai/*peer to the binary’s own copy; lazy materialization verifies and rebinds those links for the registry-facing entry’s initial import, so global installs and source worktrees can share the machine extension prefix. Import every host peer in that initial graph; launcher/child artifacts that run later must bundle their dependencies rather than resolving a mutable host-peer link after another Cotal process may have rebound it.- Bundle core as external. If you bundle (esbuild/rollup), mark
@cotal-ai/core(and any other@cotal-ai/*)--externalso the runtimeimportresolves the host’s copy, not an inlined one. - Importing the package must self-register. Your entry (
main/exports) must runregistry.register(...)as a side effect of import (e.g.export * from "./extension.js"), so the lazy materialize path can bring you online without a bespoke hook. - Name yourself. The connector
nameis the--agentvalue; it must be unique across installed extensions and must not be the reserved namecotal. - Declare a
version. The manifest pins it at add time and every load checks the installed package against that pin, soext addrefuses a package without one.
A minimal package.json:
{ "name": "@you/cotal-connector-myagent", "version": "0.1.0", "type": "module", "main": "./dist/index.js", "files": ["dist"], // whatever `ext add` needs to install + import "peerDependencies": { "@cotal-ai/core": ">=0.1.0" }}Connector lifecycle
Section titled “Connector lifecycle”cotal ext add @you/cotal-connector-myagent # installs + verifies + caches its contributioncotal spawn --agent myagent # or `agent: myagent` in a manifestcotal ext remove @you/cotal-connector-myagent # gone; nothing static-imported itSet COTAL_DEFAULT_AGENT=myagent to make it the default for a bare cotal spawn. Your connector
resolves through the same lazy-materialize path as the built-ins (in the CLI’s launch preflight and in
the manager), so a live cotal up will seed nothing extra: it imports your package, reads requires,
and launches. For runtimes (how a node is hosted: pty/tmux/…) rather than harnesses, the same
extension model applies via the Runtime contract; see define a team and
the CLI reference.