@andespindola/copilot-gateway
v0.3.0
Published
Reusable wrapper around the GitHub Copilot CLI: schema-validated JSON output, timeouts, and a warm/persistent ACP variant that keeps `copilot --acp` alive to drop per-call cold start.
Maintainers
Readme
copilot-gateway
Install
npm install @andespindola/copilot-gatewayA small, framework-agnostic TypeScript wrapper around the GitHub Copilot CLI
(copilot -p). It builds a prompt, runs Copilot non-interactively, captures its
STDOUT, and optionally validates the answer against a JSON Schema — with
first-class support for timeouts and OS-level isolation (a dedicated OS
user and/or a scoped COPILOT_HOME).
- Zero runtime dependencies — Node built-ins only (
node:child_process). - ESM, TypeScript, Node >= 18.
- Programmatic API and a CLI.
Requires the
copilotCLI installed and authenticated on the host (copilot login).
Quick start
Programmatic
import { CopilotGateway } from "@andespindola/copilot-gateway";
const gateway = new CopilotGateway({
workdir: "/home/copilotai/workspace",
defaultTimeoutMs: 90_000,
});
// Plain text
const { text } = await gateway.complete({
prompt: "Summarize the benefits of immutable data structures in 3 bullets.",
});
console.log(text);
// Schema-validated JSON
const { json } = await gateway.complete({
prompt: "Extract the city and country.",
context: "Address: 10 Downing Street, London, United Kingdom",
schema: {
type: "object",
properties: { city: { type: "string" }, country: { type: "string" } },
required: ["city", "country"],
additionalProperties: false,
},
});
console.log(json); // { city: "London", country: "United Kingdom" }CLI
# Prompt as an argument
copilot-gateway "Explain the CAP theorem in one paragraph."
# Prompt from stdin
echo "Write a haiku about TypeScript." | copilot-gateway
# JSON output validated against a schema file
copilot-gateway --schema ./schema.json "Extract the invoice total."
# Warm/persistent ACP process, printing the structured result
copilot-gateway --warm --json "Refactor suggestion for this module"
# Isolated under a dedicated OS user with a scoped COPILOT_HOME
copilot-gateway \
--run-as copilotai \
--copilot-home /home/copilotai/.copilot-fast \
--workdir /home/copilotai/workspace \
--timeout 60000 \
"Refactor suggestion for this module"Run copilot-gateway --help for the full flag list.
Warm / persistent mode (CopilotGatewayPersistent)
copilot -p pays a cold-start cost on every invocation. When a process issues
many completions, keep Copilot warm instead: CopilotGatewayPersistent
spawns and supervises one (or a small pool of) long-lived copilot --acp
processes and routes each completion through a fresh ACP session over stdio.
The expensive process/auth/tool init is paid once; each call still gets an
independent session.
import { CopilotGatewayPersistent } from "@andespindola/copilot-gateway";
const gateway = new CopilotGatewayPersistent({
runAsUser: "copilotai",
workdir: "/home/copilotai/workspace",
poolSize: 2, // run two warm children for concurrency
});
try {
const { text } = await gateway.complete({ prompt: "Summarize this in 3 bullets." });
console.log(text);
} finally {
gateway.dispose(); // kills the warm children
}It exposes the same complete({ instructions, context, prompt, schema,
timeoutMs }) shape and the same error types as CopilotGateway, so it is a
drop-in alternative for the request path. Differences to be aware of:
- Requires
copilot --acp. Each child is launched ascopilot --acp(wrapped insudo -u <user> -H --whenrunAsUseris set), performs the ACPinitializehandshake, then serves onesession/new+session/promptpair per completion. Streamedagent_message_chunktext is accumulated into the answer. - A session per call. The warm
copilot --acpprocess stays alive across calls, but everycompleteopens a newsession/new(withcwdand an emptymcpServerslist) so completions stay independent. - Lazy + supervised. Children spawn on the first
complete, not at construction. On any child crash, exit, protocol error, or timeout the in-flight request is rejected (so the caller can fall back) and the child is respawned lazily on the next call. A pure JSON parse failure keeps the worker warm. - Serialized per child. stdio is shared, so each child handles one in-flight
completion at a time. Set
poolSize(default1) for concurrency. - Schema mode by prompt injection. ACP has no output-schema flag, so the
JSON Schema is injected into the prompt as a "respond with only JSON
conforming to this schema" instruction; the result is parsed leniently with
one retry before a
CopilotJsonErroris thrown. - Lifecycle. Always call
dispose()(aliasclose()) on shutdown to kill the children.
Persistent options
In addition to bin, runAsUser, workdir, copilotHome, defaultTimeoutMs,
model, allowAllTools and contextLabel (same meaning as below),
CopilotGatewayPersistent accepts:
| Option | Type | Default | Description |
| -------------------- | -------- | ------- | --------------------------------------------------------------- |
| poolSize | number | 1 | Number of warm copilot --acp children to supervise. |
| handshakeTimeoutMs | number | 20000 | Timeout for the ACP initialize handshake of a fresh child. |
Warm daemon (serve / call)
For cross-process reuse — e.g. a synchronous consumer that cannot hold a warm
CopilotGatewayPersistent in memory — the CLI can run the persistent gateway as
a background daemon behind a unix socket. Any number of short-lived callers
then reach the same warm copilot --acp children over that socket.
# Start the daemon in the foreground (Ctrl-C / SIGTERM to stop)
copilot-gateway serve --model gpt-5.4 --workdir /home/copilotai/workspace --pool 2
# Send one request and print the JSON response (auto-starts the daemon if absent)
echo '{"prompt":"Summarize this in 3 bullets."}' | copilot-gateway callserve boots the daemon and keeps it listening until it is killed or idles out:
--socket <path>— explicit unix socket path (default: derived from bin/model/workdir under the system temp dir, so distinct configs get distinct daemons).--model <id>,--workdir <dir>,--run-as <user>,--copilot-home <dir>,--allow-all-tools— same meaning as the base CLI; they parameterize the warm gateway behind the socket.--pool <N>— number of warm children (concurrency).--idle-timeout <ms>— exit after this long with no requests (default300000).
call reads a single JSON CompleteRequest ({ prompt, instructions?,
context?, schema?, timeoutMs? }) from stdin, forwards it to the daemon, and
prints one JSON WireResponse ({ ok, text?, json?, error?, errorName? }) to
stdout. The exit code is 0 when ok is true, 1 otherwise. It
auto-starts a detached daemon when none is running; pass --no-start to fail
fast instead of spawning one. Because it is a one-shot request/response over
stdio, call is convenient to drive synchronously (e.g. spawnSync).
Programmatic API
The same primitives are exported for direct use:
import { startDaemon, callDaemon, resolveSocketPath } from "@andespindola/copilot-gateway";
const { socketPath, close } = await startDaemon({ model: "gpt-5.4", poolSize: 2 });
const response = await callDaemon({ prompt: "Summarize this in 3 bullets." }, { socketPath });
console.log(response); // { ok: true, text: "…" }
close(); // stop the daemonresolveSocketPath(options)— the stable socket path for a given configuration (respects an explicitsocketPath).startDaemon(options)— start the socket server backing aCopilotGatewayPersistent; resolves to{ socketPath, close }.callDaemon(request, options)— send one request and resolve theWireResponse; auto-starts the daemon unlessautoStart: false.
Options
Constructor: new CopilotGateway(options?).
| Option | Type | Default | Description |
| ------------------ | --------- | --------------- | --------------------------------------------------------------------------- |
| bin | string | "copilot" | Path/name of the Copilot binary. |
| runAsUser | string | (none) | Run Copilot isolated under this OS user via sudo -u <user> -H. |
| workdir | string | process.cwd() | Working directory the Copilot process runs in. |
| copilotHome | string | (inherited) | Value for COPILOT_HOME of the spawned process (preserved across sudo). |
| defaultTimeoutMs | number | 90000 | Default per-call timeout in milliseconds. |
| model | string | (none) | Model id forwarded as --model (e.g. "auto", "gpt-5.4"). |
| allowAllTools | boolean | false | Let the agent run shell/edit tools automatically (--allow-all-tools). |
| contextLabel | string | "Context" | Label for the optional context block injected into the prompt. |
Security — untrusted input.
allowAllToolsis off by default. In that mode a generation call cannot touch the user's files: the one-shot path never passes--allow-all-tools, and the persistent path denies anysession/request_permissionthe agent raises (and refusesfs/*requests outright). Only enableallowAllToolsfor trusted, agentic use where Copilot is allowed to run tools; never for a public/untrusted request path.
Request: gateway.complete(request).
| Field | Type | Description |
| -------------- | -------------------------- | ------------------------------------------------------------------- |
| prompt | string (required) | Main task/question. Must be non-empty. |
| instructions | string | Persona/system instructions prepended to the prompt. |
| context | string | Context injected as a labeled block (see below). |
| schema | Record<string, unknown> | JSON Schema; when present, the result is returned parsed in json. |
| timeoutMs | number | Per-call timeout override. |
Result: { text: string; json?: unknown }. json is only present when a
schema was supplied.
Isolation patterns
Dedicated OS user (permission jail)
Set runAsUser to launch Copilot as a dedicated, least-privileged OS user via
sudo -u <user> -H --. Isolation is delegated to the operating system. This
expects a sudoers rule allowing the service account to run copilot as the
dedicated user without a password prompt.
Scoped COPILOT_HOME (speed)
Point copilotHome at a lightweight Copilot configuration directory — for
example, one without heavy MCP servers — to get faster, more deterministic runs.
The value is exported as COPILOT_HOME in the spawned process environment and
is preserved across sudo when runAsUser is also set.
JSON Schema output
When schema is provided, the gateway appends a strict "answer only with JSON"
directive to the prompt and parses the output. Parsing tolerates incidental text
around the JSON value; if nothing parseable is found it throws
CopilotJsonError. In warm mode the parse is retried once (a fresh session)
before giving up.
Injecting external context
The gateway does not fetch context for you. The caller owns context
retrieval — e.g. a memory/RAG system, a knowledge base, or retrieved documents —
and passes the assembled string through request.context. It is injected as a
labeled block (contextLabel, default "Context") ahead of the prompt.
Error types
All errors extend CopilotError.
| Error | Thrown when… |
| ------------------------- | -------------------------------------------------------- |
| CopilotTimeoutError | The call exceeds the timeout (process killed / rejected). |
| CopilotExecError | Copilot exits non-zero or an ACP error/crash occurs. Exposes code and stderr. |
| CopilotJsonError | Schema mode requested but output is not valid JSON (also empty prompt). |
| CopilotEmptyOutputError | Copilot produced no output. |
import { CopilotGateway, CopilotTimeoutError, CopilotExecError } from "@andespindola/copilot-gateway";
try {
await new CopilotGateway().complete({ prompt: "..." });
} catch (err) {
if (err instanceof CopilotTimeoutError) {
// retry with a larger timeout
} else if (err instanceof CopilotExecError) {
console.error(err.code, err.stderr);
}
}Development
npm install
npm run build # tsc -> dist/
npm run typecheck # tsc --noEmit
npm test # vitest (spawn is mocked; no real copilot needed)License
MIT © 2026 Anderson Faustino Lima
