Skip to content

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.

On every CLI invocation, the loader looks in the current working directory for, in this exact order:

  1. pangolin.config.ts
  2. pangolin.config.js
  3. pangolin.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>.

ExportUsed byRequired
default or named clientAll PangolinClient-backed commands (capabilities, subagent, env, dispatch, deploy)The client surface. Errors if neither is present.
named orchThe pangolin orch familyOnly when running an orch verb. Errors lazily (clear message) if an orch verb runs without it.
named syncProviderscapabilities sync / subagent sync, only when --provider names something the built-ins (claude-code, stoa) don’t coverOptional. 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.

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.

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,
};

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:

LaneEnv varBillingMint it
API keyANTHROPIC_API_KEYYour Anthropic API organization, per tokenconsole.anthropic.com
SubscriptionCLAUDE_CODE_OAUTH_TOKENYour 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-key forces a lane.
  • Otherwise it auto-detects: a non-empty CLAUDE_CODE_OAUTH_TOKEN → subscription; else ANTHROPIC_API_KEY.

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:

OptionWherePurpose
endpoint, forcePathStyle, regionnew S3StorageProvider({ bucket, endpoint, forcePathStyle: true, region })Point content-addressed storage at the custom endpoint (or inject a pre-built client).
S3Mailboxnew MailboxSubmissionTransport(new S3Mailbox(s3MailboxClient))The submission inbox/outbox over S3 (the cross-machine analogue of LocalDirMailbox).
AwsSecretStore + AWS_ENDPOINT_URL_SECRETS_MANAGERsecretStores: { aws: new AwsSecretStore() } + the endpoint env on serve & workersSecrets (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 envThe worker builds its own S3 client at boot to fetch bundles/upload patches; it reads these to reach the same endpoint.
extraEnvnew 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.