pangolin.config reference
The pangolin CLI and the MCP server resolve a pangolin.config file in the
current working directory to obtain the PangolinClient they operate on (and, for
the orch family, an OrchContext). Integrators typically keep one
pangolin.config.mjs in their deploy repo.
Resolution order
Section titled “Resolution order”On every CLI invocation, the loader looks in the current working directory for, in this exact order:
pangolin.config.tspangolin.config.jspangolin.config.mjs
The first file that exists is dynamically imported. If none exist, the CLI
errors with no pangolin.config.{ts,js,mjs} found in <cwd>.
What it must export
Section titled “What it must export”| Export | Used by | Required |
|---|---|---|
default or named client | All PangolinClient-backed commands (capabilities, subagent, env, dispatch, deploy) | The client surface. Errors if neither is present. |
named orch | The pangolin orch family | Only when running an orch verb. Errors lazily (clear message) if an orch verb runs without it. |
named syncProviders | capabilities sync / subagent sync, only when --provider names something the built-ins (claude-code, stoa) don’t cover | Optional. See syncProviders below. |
default and client are interchangeable for the client — the loader takes
mod.default ?? mod.client. The orch export is an OrchContext:
interface OrchContext { transport: SubmissionTransport & ControlChannel; anchor?: AuditAnchor; storage?: { get(ref: string): Promise<Uint8Array> }; verifySignature?: (root: Uint8Array, sig: Signature) => boolean; runService?: (signal: AbortSignal) => Promise<void>; // pre-wired serve() for `pangolin orch serve` scheduleStore?: ScheduleStore; // config-owned; required for `pangolin orch schedule` verbs}runService is required only for pangolin orch serve; the client verbs use
transport (plus anchor/storage for status/watch/audit).
scheduleStore is required only for the pangolin orch schedule add|list|rm verbs;
omitting it has no effect on any other verb. The default implementation is
SqliteScheduleStore from @quarry-systems/pangolin-orchestrator, which persists
schedules in a dedicated schedules table on the same SQLite database used by
SqliteRunStateStore. Pass the same dbPath to both so they share one file:
import { SqliteRunStateStore, SqliteScheduleStore, serve,} from '@quarry-systems/pangolin-orchestrator';
const dbPath = join(tmpdir(), 'my-run-state.db');const store = new SqliteRunStateStore(dbPath);const scheduleStore = new SqliteScheduleStore(dbPath);
export const orch = { transport, runService: (signal) => serve({ orchestrator, transport, scheduler, signal }), scheduleStore,};Custom implementations can satisfy the ScheduleStore interface directly.
syncProviders
Section titled “syncProviders”Optional. An array of SyncProvider instances; see
Authoring a new sync provider.
Loaded lazily — only when --provider names something the built-in
providers (claude-code, stoa) do not cover, so a config with no
syncProviders export pays no cost on every other command.
Note this file is imported by two processes: the pangolin CLI, where
getSyncProviders is genuinely lazy (only a --provider miss on the
built-ins evaluates it), and the pangolin-mcp server, which has its own
config loader and imports the whole pangolin.config module — module-scope
code included — at startup, whether or not any sync verb ever runs. A
syncProviders expression like [new RemoraProvider()] therefore constructs
your provider (and executes anything your provider package does at module
scope) as a side effect of pangolin-mcp starting up. See the import-safety
requirement
for out-of-tree providers.
Worked pangolin.config.mjs
Section titled “Worked pangolin.config.mjs”This is the config from
examples/offload-fanout/,
which wires both a client and an orch context against the local
provider stack. It is import-safe: no throw at load when ANTHROPIC_API_KEY is
absent.
import { mkdtemp } from 'node:fs/promises';import { tmpdir } from 'node:os';import { join } from 'node:path';
import { PangolinClient, NoopCredentialProvider, StdoutResultSink } from '@quarry-systems/pangolin-client';import { LocalStorageProvider } from '@quarry-systems/pangolin-storage-local';import { LocalDockerProvider } from '@quarry-systems/pangolin-providers-local-docker';import { LocalSecretStore } from '@quarry-systems/pangolin-secret-store';import { PangolinOrchestrator, SqliteRunStateStore, ManualTrigger, DispatchExecutor, AuditLog, LocalAnchor, createLocalSigner, verifyEd25519, MailboxSubmissionTransport, LocalDirMailbox, serve,} from '@quarry-systems/pangolin-orchestrator';
const rootDir = join(tmpdir(), 'pangolin-fanout-storage');const secretDir = join(tmpdir(), 'pangolin-fanout-secrets');const mailboxDir = join(tmpdir(), 'pangolin-fanout-mailbox');const dbPath = join(tmpdir(), `pangolin-fanout-${process.pid}.db`);
const workerImage = 'ghcr.io/quarrysystems/pangolin-worker:latest';
// PangolinClient — lazy: no Docker/network until dispatch fires.export const client = new PangolinClient({ namespace: 'offload-fanout', compute: { 'local-docker': new LocalDockerProvider({ allowUnpinnedImage: true }) }, storage: new LocalStorageProvider({ rootDir }), secretStores: { local: new LocalSecretStore({ dir: secretDir }) }, credentials: { none: new NoopCredentialProvider() }, targets: { local: { compute: 'local-docker', credentials: 'none', secretStore: 'local' } }, resultSink: new StdoutResultSink(),});
export default client;
// Live dispatch lifecycle events (opt-in; default drops them). Prints one JSON// line per accepted/started/finished/needs_input/failed/cancelled event.// import { ConsoleTelemetryHook } from '@quarry-systems/pangolin-client';// const client = new PangolinClient({ /* …, */ telemetry: new ConsoleTelemetryHook() });
// Metrics (opt-in; default records nothing). One shared recorder feeds three sinks:// the dispatch lifecycle, the orchestrator engine, AND the serve /metrics endpoint:// import { InMemoryMetricsRecorder } from '@quarry-systems/pangolin-core';// import { MetricsTelemetryHook, combineTelemetryHooks, ConsoleTelemetryHook } from '@quarry-systems/pangolin-client';// const metrics = new InMemoryMetricsRecorder();// const client = new PangolinClient({ /* …, */// telemetry: combineTelemetryHooks(new ConsoleTelemetryHook(), new MetricsTelemetryHook(metrics)) });// const orchestrator = new PangolinOrchestrator({ /* …, */ metrics }); // SAME recorder// // Expose it: serve() opens an OPT-IN HTTP endpoint (default off) with /healthz// // (heartbeat liveness — detects a wedged tick loop), /readyz (readiness — red when// // a tick can't reach SQLite/the mailbox), and /metrics (Prometheus text):// // serve({ orchestrator, transport, signal,// // http: { port: 9464, metricsSnapshot: () => metrics.snapshot() } });// // Unauthenticated by design — bind to a trusted/internal interface; do NOT publish// // /metrics to the public internet. (The endpoint is live; the current posture is// // Prometheus scrape-pull — a push/OTel adapter is the only deferred piece.)// // Note: runs_completed_total + audit_dropped_appends are recorded at the audit seal, so they// // require an AuditLog; the dispatch + queue/retry/deadline metrics do not.
// Audit + orchestrator setup (import-safe: constructors are lazy / in-memory).const store = new SqliteRunStateStore(dbPath);process.on('exit', () => { try { store.close(); } catch {} });const signer = createLocalSigner();const anchor = new LocalAnchor(store);const auditLog = new AuditLog({ store, signer, anchor, // timestamper: new Rfc3161TimestampAuthority({ url: 'https://freetsa.org/tsr' }), // optional RFC 3161 trusted time // A dropped audit append means the sealed record is incomplete — it is logged // loudly by DEFAULT (a SOC2 / EU AI Act Art 12 completeness control; the same // applies to a TSA failure via onTimestampFailure). Wire onDrop only to OVERRIDE // the default (e.g. route to a metrics counter). `auditLog.droppedAppends` holds // the running total. onDrop: (entry, err) => console.error(`[pangolin] AUDIT DROP kind=${entry.kind} run=${entry.runId}:`, err),});
const orchestrator = new PangolinOrchestrator({ store, executors: { dispatch: new DispatchExecutor({ client, target: 'local', workerImage, // One Claude credential, staged through the per-dispatch secret lane. // The `claudeAuthSecrets()` helper builds this map and also supports a // Pro/Max subscription token — see "Authentication" below. secrets: { ANTHROPIC_API_KEY: { inline: process.env.ANTHROPIC_API_KEY ?? '' }, }, }), }, triggers: { manual: new ManualTrigger() }, queues: { default: { concurrency: 2 } }, // Wall-clock dispatch deadline: a dispatch running longer than this is force-failed // (and best-effort cancelled to reap the worker), freeing its concurrency slot + // resource locks. Defaults to 7_200_000 (2h); raise it for legitimately long runs. maxRuntimeMs: 7_200_000, auditLog,});
const verifySignature = (root, sig) => verifyEd25519(root, sig, signer.publicKey);const transport = new MailboxSubmissionTransport(new LocalDirMailbox(mailboxDir));const runService = (signal) => serve({ orchestrator, transport, signal });
export const orch = { transport, storage: client.storage, anchor, verifySignature, runService,};Authentication: API key or subscription
Section titled “Authentication: API key or subscription”The stock worker image runs the claude-code runtime adapter, which spawns the
claude binary. That binary needs a Claude credential, supplied through the
per-dispatch secret lane
as one of two mutually-exclusive env vars:
| Lane | Env var | Billing | Mint it |
|---|---|---|---|
| API key | ANTHROPIC_API_KEY | Your Anthropic API organization, per token | console.anthropic.com |
| Subscription | CLAUDE_CODE_OAUTH_TOKEN | Your Claude Pro/Max subscription (no API credits) | claude setup-token on a machine with a browser; paste the sk-ant-oat01-… token |
claudeAuthSecrets(env = process.env) (from @quarry-systems/pangolin-core)
builds the secrets map for you and returns { mode, credentialName, present, secrets }:
import { claudeAuthSecrets } from '@quarry-systems/pangolin-core';
const auth = claudeAuthSecrets(); // reads process.env// auth.mode → 'subscription' | 'api-key'// auth.present → false when the chosen credential is empty (gate your live run on this)// auth.secrets → pass straight to DispatchExecutor's `secrets`It stages exactly one credential — never both. This matters: the claude
CLI ranks ANTHROPIC_API_KEY above CLAUDE_CODE_OAUTH_TOKEN, so if both
reached the worker the API key would silently win and bill credits. Selection:
PANGOLIN_CLAUDE_AUTH=subscription|api-keyforces a lane.- Otherwise it auto-detects: a non-empty
CLAUDE_CODE_OAUTH_TOKEN→ subscription; elseANTHROPIC_API_KEY.
Config keys reference
Section titled “Config keys reference”The PangolinClient constructor options are documented in full on the
PangolinClient API page — namespace,
compute, credentials, storage, targets, secretStores, telemetry,
resultSink, defaultModel, and dispatchRetention. The targets map keys
each become a valid --target value for pangolin dispatch run; each target’s
compute, credentials, and secretStore must reference a name present in
the corresponding option map.
Targeting a self-hosted / S3-compatible store (MinIO, LocalStack)
Section titled “Targeting a self-hosted / S3-compatible store (MinIO, LocalStack)”The S3 seams accept a custom endpoint, so the whole stack can run against MinIO,
LocalStack, or any S3-compatible store — no AWS account required. The worked
example is examples/offload-minio/
(a serve container + MinIO via docker-compose). The relevant options:
| Option | Where | Purpose |
|---|---|---|
endpoint, forcePathStyle, region | new S3StorageProvider({ bucket, endpoint, forcePathStyle: true, region }) | Point content-addressed storage at the custom endpoint (or inject a pre-built client). |
S3Mailbox | new MailboxSubmissionTransport(new S3Mailbox(s3MailboxClient)) | The submission inbox/outbox over S3 (the cross-machine analogue of LocalDirMailbox). |
AwsSecretStore + AWS_ENDPOINT_URL_SECRETS_MANAGER | secretStores: { aws: new AwsSecretStore() } + the endpoint env on serve & workers | Secrets (e.g. the Claude credential — API key or subscription token) staged into Secrets Manager — LocalStack for self-host, real SM on AWS. Network-reachable, so it crosses the serve→worker boundary; refs-only in the audit. |
PANGOLIN_S3_ENDPOINT (+ AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION) | worker container env | The worker builds its own S3 client at boot to fetch bundles/upload patches; it reads these to reach the same endpoint. |
extraEnv | new LocalDockerProvider({ extraEnv: { PANGOLIN_S3_ENDPOINT, AWS_*, AWS_ENDPOINT_URL_SECRETS_MANAGER } }) | Delivers the worker-boot env above (S3 bootstrap + the Secrets Manager endpoint) to every launched worker container. |
The S3 endpoint/creds must reach the worker as container env (via extraEnv),
not via a bundle — the worker needs S3 access before it can resolve anything else.
Secrets (the Claude credential) go the proper secret lane — staged into a
network-reachable SecretStore (Secrets Manager) and resolved by the worker over
the wire, not a bundle value. Non-secret config travels as
env bundles
(content-addressed storage, reach workers).
On real AWS none of this is needed: the default S3 endpoint + an IAM task role + AWS Secrets Manager all work without custom endpoints or
extraEnv. LocalStack just stands in for Secrets Manager (and the S3 opts for MinIO) when self-hosting.