Skip to content
PAC

Architecture

What a node is made of on main, as of the date in the footer. The design page says what is meant; this page says what exists, where, and what does not yet. Every claim names a file, a key, or a route in the repository that owns it, and repositories says which of those to clone.

Two processes

A node is two processes. pac-daemon holds the identity, the wire, the stores, and the installed plugins: it boots the spine, serves the control API, and supervises the worker -- restarting it when it dies, stopping it first on shutdown (daemon/src/main.ts). pac-worker executes model runs in a process of its own and never opens the node's store: it is told PAC_DATA_DIR and PAC_API_URL, pulls turns over three routes of the control API -- POST /worker/claim, POST /worker/turns/:runId/:turn/renew, POST /worker/turns/:runId/:turn/settle (sdk/src/worker.ts) -- and holds each under a lease it renews while a model runs. The model's process is the least trusted thing in the node, which is why the split is a process boundary and not a module one.

principal ── pac cli · mcp · page · desktop ──▶ 127.0.0.1:7452  (a token when not loopback)
                                                     │
┌──────────────────────────── pac-daemon ─────────────┴──────────────────────────────┐
│ spine.yml     trace identity network discovery intelligence data host api          │
│ plugins.yml   transport-iroh discovery-directory records … operation-jc-box meta   │
│ core routes   /health /metrics /traces /sessions /peers /runs /plugins             │
│ plugin routes /flows/<plugin id>/*          worker routes /worker/*                │
└──────┬──────────────────────────┬──────────────────────────────┬───────────────────┘
       │ iroh (QUIC)              │ https, writes signed          │ claim · renew · settle
       ▼                          ▼                              ▼
 a peer's pac-daemon      node-directory.projectpac.net     pac-worker
                          plugin-registry.projectpac.net    defaultWorker/executors.yml
                                                            executor-cli rows: pi | claude
 operation-jc-box ── RA-TLS, pinned measurements ──▶ the box (jc-tee-vm)

The data directory

One folder per plugin, core and non-core alike, directly under the data directory (~/.pac/data for a node the cli set up). A plugin's folder is its own: its database, its keys, whatever it wrote. Removing the plugin leaves the folder for a later install of the same id to find. From pac-node/README.md:

<data dir>/
  spine.yml  plugins.yml                  the entry files
  modules/                                an npm prefix: what setup seeded, what a source fetched
  trace/  identity/  network/  intelligence/
  defaultWorker/                          the worker's own: its executors.yml, and a folder per executor row
  <plugin id>/                            one per installed plugin

The names the node's own state uses are refused as plugin ids at install (core/host/src/layer.ts, RESERVED_DIR_NAMES). A plugin's folder is derived from its identity, never passed; readUnderCaller is the one bounded way another plugin's files are opened for it, and it refuses a path that escapes (sdk/src/files.ts).

The spine

Eight rows in spine.yml, one per core plugin, written on first boot with every default filled in and thereafter only added to (daemon/src/spine.ts, spineRows). In boot order:

rowpackageclaimsstate
trace@projectpac/core-tracectx.tracebuilt
identity@projectpac/core-identityctx.identitybuilt
network@projectpac/core-networkctx.networkbuilt
discovery@projectpac/core-discoveryctx.discoverybuilt
intelligence@projectpac/core-intelligencectx.intelligencebuilt
data@projectpac/core-datanothinga stub: mounts, claims no service key, does nothing (README)
host@projectpac/core-hostctx.host, and provides ctx.selfbuilt
api@projectpac/core-apictx.apibuilt

Trace boots first because it must record what a node did whether or not the node has a key yet. A failed spine row aborts the boot; a failed installed plugin appears in the settle audit with its reason and the node keeps running (daemon/README.md). Installed plugins are the rows of plugins.yml, the host's to rewrite, and which tree an entry belongs to is what tells a core plugin from an installed one when a caller is derived.

What a plugin compiles against

@projectpac/sdk (pac-node/sdk): contracts and the schemas that parse them at a boundary, no behaviour. Importing anything from it brings the one cordis Context/Events declaration merge into scope (sdk/src/context.ts), which is why a plugin never imports a core package.

ctx.contractwhat it is
identityIdentitynode id, identities, sign, verify
networkNetworkroute, openSession, send, fetch, serve, closeSession
discoveryDiscoveryadvertise (returns a disposer), query, adapter
intelligenceIntelligencestartRun, appendTurn, startAndAppendTurn, closeRun, get
hostHostinstall, remove, enable, disable, kill, list, packageSource
traceTraceappend, query, checkpoint, reconstruct a session or a run
apiControlApihandle, callRoute
selfPluginBindingthe calling plugin's id, its default network identity node, its dir, log
executorsExecutorsthe worker's registry -- provided in the worker process alone
eventdispatched bywhen
core/messagenetworkan envelope for one of this plugin's sessions arrived
core/message-failednetworkan outbound message exhausted its retries
core/turn-settledintelligenceone settled turn, into the scope of the plugin that started the run, once
core/removinghostthis plugin is being removed and is still itself -- the only exactly-once event
core/peer-unreachablenetworkbetween delivery retries; discovery listens and re-runs its remembered needs

Events are dispatched in parallel mode and delivered at least once, so a handler is idempotent on the ids it carries.

A manifest (sdk/src/manifest.ts) is id, kind, serviceKey?, version, payloadTypes, routes. kind is one of flow, flow-service, source, operation, tool, executor, transport, discovery-adapter, package-source; it is descriptive, and nothing in the kernel loads, routes, or resolves by it. The manifest is declared in TypeScript and copied into package.json#pac.manifest by flow-devkit/src/sync-manifests.ts, gated by manifests:check, so a node reads it without importing the module. SPOKEN_KIND = "flow" (sdk/src/discovery.ts) is the advertisement kind every flow announces itself under; it shares a string with the manifest kind and nothing couples them.

Two surfaces, not one. Context is what an installed plugin compiles against. InternalSurfaces (sdk/src/readers.ts) is what one core plugin, the daemon, or the control API reads of another core plugin -- session readers, run readers, the turn broker -- reached through internals(ctx, key) and never widened at a call site. The layering is a test, not a convention: the sdk depends on cordis and zod alone, lib on the sdk at most, core plugins never on a process, and no link: in any manifest (sdk/tests/dependencyRule.spec.ts).

Caller derivation

Every seam repeats one mechanism. An operation derives which plugin called it from the accessing context (sdk/src/caller.ts, callerOf): the fiber is walked to the loader entry, and the tree that entry belongs to -- spine or plugins -- says whether the caller is core or installed. Nothing is named by the caller, so nothing can be claimed for another plugin. Every registration a plugin makes -- a route, an advertisement, a served resource, a record store -- is an effect of the calling scope, so disabling the plugin withdraws all of it with no disposer anybody has to remember to call. ctx.self is one service answering per caller (core/host/src/binding.ts). This is why the host does not sit between a plugin and the core: there is nothing for it to mediate that the seams do not already know.

The wire

An envelope (sdk/src/network.ts) is sender, senderPublicKeyPem, recipient, plugin, pluginVersion, sessionId, messageId, payloadType, payload, signature; the signature covers every field above it, and the carried key derives sender, so an envelope self-certifies. Bounds (core/network/src/wire.ts): MAX_FRAME_BYTES 1 MiB, MAX_PAYLOAD_BYTES 256 KiB, MAX_RESOURCE_BYTES 8 MiB.

Inbound, in order: the key derives the claimed sender, the signature verifies, the envelope names this node, and it names a plugin and version this node has a route for; then dedupe, open the session if it is new, store, ack, dispatch core/message. A refusal tells the sender to stop retrying. Outbound is an outbox: a message is signed when queued so a retry is identical bytes, claimed under a lease, retried maxAttempts (5) times from retryDelayMs (1 s), and reported as core/message-failed when exhausted (core/network/src/index.ts). A session belongs to one plugin: send against another plugin's session is refused, and so is a payload type the plugin's route did not declare. fetch rides the envelope under the reserved type network/fetch. The one transport is @projectpac/transport-iroh: QUIC, one ALPN, its own key, relays as config; it moves bytes and reads nothing.

The control API

Fastify, bound to 127.0.0.1:7452 by default (core/api/src/index.ts); a non-loopback bind without a token of at least 16 characters is refused at load and again in the daemon's preflight. Core routes:

methodpath
GET/health, /metrics, /traces, /sessions, /peers, /runs, /plugins
POST/plugins (install), /plugins/:id/enable, /plugins/:id/disable, /plugins/:id/kill
DELETE/plugins/:id
POST/worker/claim, /worker/turns/:runId/:turn/renew, /worker/turns/:runId/:turn/settle
any/flows/:plugin/* -- every plugin's own routes

A plugin registers a route with ctx.api.handle(method, path, handler). The caller is derived, the pair is refused unless the plugin's manifest declared it, and it is served at /flows/<plugin id>/<path> -- one catch-all rather than a Fastify route per registration, because plugins come and go after the server is listening. callRoute(plugin, method, path, body) is the same dispatch without HTTP, run as the target plugin's own handler; the router uses it so the work it starts is attributed to the flow whose route it is.

Runs, turns, the worker

A run (sdk/src/intelligence.ts) names an executor row and carries a payload for it and metadata for the caller, both stored and never read by the kernel. Runs are linear: the claim rule (core/intelligence/src/store.ts) hands out a pending turn only when no earlier turn of its run is still open. The worker holds a claimed turn under a lease of turnLeaseMs (30 s), renewing at a third of it; a lapsed lease fails the turn as interrupted and never re-queues it, because a model may already have acted. core/turn-settled is dispatched once into the scope of the plugin that started the run, and dispatch is gated until the daemon says the plugins tree has settled, so a turn that settled while the node was down is not announced into an empty scope. A worker silent for workerStaleMs (30 s) is counted dead.

The worker composes its own executors from defaultWorker/executors.yml. The one executor is @projectpac/executor-cli: a command line that takes the prompt on stdin and answers on stdout. pi and claude are two rows of it, not two packages, and a turn's process inherits nothing of the parent's environment but what the row passes through. It has no manifest because the worker mounts it, not the host. Prompt composition and output parsing are not the kernel's: @projectpac/prompting is functions a flow calls -- composePrompt, interpret -- with the context delimited and labelled as data, so text of unknown origin cannot close a delimiter.

Identity, discovery, trace

Identity (core/identity): ed25519. The node id is the sha256 of the public key's DER, as hex. sign(bytes, as?) unnamed signs with the node key; identities() lists the network identities and excludes it. Keys are written aside, mode 0600.

Discovery (core/discovery): advertise returns a disposer that withdraws from every adapter that took the advertisement; query returns candidates as {peer, advertisement, adapter}; the plugin re-registers this node with every adapter every republishMs (30 s). An adapter that has not answered in queryTimeoutMs (5 s) is treated as down, and the last mirrored answer is returned and traced. The one adapter is @projectpac/discovery-directory, which lives in pac-network beside the server it speaks to, because the two halves share protocol.ts.

Trace (core/trace): trace/trace.jsonl, appended synchronously; append never fails into the caller's path. Each record's prev is the sha256 of the canonical JSON of the record before it, from a genesis of 64 zeros (chain.ts). A checkpoint -- at, head, count, signed by the node key under pac/trace-checkpoint/1 -- is written every checkpointEveryMs (5 min). Records older than retainMs (30 days) move to an archive beside the file every pruneEveryMs (1 h); nothing is deleted. The actor is derived from the caller, the trigger is supplied.

The host

Install has two shapes (core/host/src/index.ts). { package } names something the node does not have: an enabled package source fetches it into modules/, the host reads the manifest out of the package's own package.json -- executing nothing -- and the row carries what the source said vouched for the bytes. { source, id } names a module already on the machine; its provenance is missing, by hand. Provenance is { vouched: "verified" | "invalid" | "missing", by, evidence? } (sdk/src/host.ts), and the kernel reads vouched alone. Config.accept maps a source id to "verified" or "any", with * for the rest and any the default; invalid is accepted by nobody. The layer rule (layer.ts) refuses a fetched package that claims a spine service key or a reserved name. remove dispatches core/removing into the plugin while it still runs and leaves its folder; kill differs from disable only across a restart.

Three package sources exist: packages-npm (npm install --ignore-scripts, names or paths only), packages-git (git+https://…#ref, shallow), and packages-checkouts (a registry name answered from a working tree beside this one -- what every dev lane installs through, so a lane runs the same install a released node runs).

Installed plugins

Everything below lives in pac-plugins at adapters/<core module>/<seam>/<name> or flow-services/<name>, except where a row says otherwise, and the table mirrors that tree. Each row links its README.

kindpackageclaimswhat
operationoperation-jc-boxctx.jcBoxsubmit named sources to a verified box, encrypted; the party key is the adapter's
operationoperation-llmctx.llmthe python a box program needs to ask a model; makes no call itself
operationoperation-tscctx.tsctype-check TypeScript a flow was handed
operationoperation-pexectx.pexecompile an object-toolchain draft and build its box envelope
operationoperation-dobjctx.dobjthe write half of an object daemon
sourcesource-web-exportsctx.webExportsdata a service handed the principal back, with a sentence saying what is inside
sourcesource-skillsctx.skillsprocedures the principal wrote; the front matter kept apart from the body
sourcesource-llmctx.modelsthe principal's model access, offered like anything else they hold
sourcesource-agent-historyctx.agentHistorywhat they work on, read from their coding-agent transcripts by a model
sourcesource-conversation-notesctx.conversationNotestheir conversations, by way of files the assistant writes about itself
sourcesource-intentsctx.intentswhat they want done, extracted from the other sources
package-sourcepackages-npm, packages-git, packages-checkouts--registered on ctx.host
executorexecutor-cli--mounted by the worker's executors.yml
transporttransport-iroh--registered on ctx.network
flow-serviceartifacts-gitctx.artifactsthe versioned resource two peers negotiate over; git for history and diff
flow-servicenegotiationctx.negotiationthe suggest-and-agree rounds, as a factory a flow drives with its own hands
flow-servicejc-box-runctx.jcBoxRunthe box session as durable steps: party, propose, view, approve, submit, fetch
flow-servicerecordsctx.recordsa plugin's durable rows, in its own folder, the caller derived
flow-serviceresultsctx.resultswhat every flow produced, read in one place
flow-servicesourcesctx.sourceswhat this node holds, read in one place
--prompting--functions, not a plugin: compose a prompt, interpret the answer
flow-servicerouter, in its own repositoryctx.routera sentence decided into a declared intent and started through callRoute
discovery-adapterdiscovery-directory, in pac-network--registered on ctx.discovery

Also in pac-plugins and not plugins: lib/, @projectpac/plugin-lib -- the artifact-store contract, the material a source hands over, the offers a descriptor carries, and the helpers every flow writes first -- and flow-devkit/, the lane scripts and the fixture corpus the flow repositories share. artifacts-memory is the artifact store's test double and is not published. A source answers inventory() -- the offers it puts in a descriptor for a peer -- and stake(); a flow never reads a source's contents.

Services that are nobody's node

Both live in pac-network, both belong behind https, and anyone may run either: a network is whichever nodes point at the same ones.

node directoryplugin registry
package@projectpac/node-directory@projectpac/plugin-registry
holdsclaims nodes make about themselves: presence, and advertisementsone operator's list of what a node could install, with a sentence per entry
who writesany node, signed by its node key under a domain string, within MAX_SIGNED_AGE_MS (5 min)the operator, with a bearer token; the registry does not start without one
routesPUT /nodes/:node, PUT /advertisements, POST /withdrawals, POST /queries, GET /healthGET /plugins, GET /plugins/:id/readme, PUT /plugins/:id, DELETE /plugins/:id, GET /health
lifetimepresence is a read-time filter: a node not heard from in presenceTtlMs (5 min) stops being returned; nothing is deleteddurable until removed; the catalogue is registered by walking checkouts' manifests, never committed
default port7460 (the lanes run it on 7451)7461
productionnode-directory.projectpac.netplugin-registry.projectpac.net

The protocol is owned by the plugin half (protocol.ts) and imported by the server, because they are two halves of one wire.

The box

jc-tee-vm is the joint-computation box: a long-lived attested runtime that runs a program every party approved, once, over inputs only it decrypts. It terminates TLS 1.3 itself with a boot-time key and carries its attestation quote inside the certificate (report_data = sha256(cert SPKI)), so a client verifies the runtime during the handshake and every request rides the attested channel (src/common/ra-tls.ts). The protocol:

  1. POST /session with the program, the runner, and the participants' keys. The set and the runner are fixed here; the reply is the session id and sha256(program).
  2. Each party reviews GET /session/:id/program offline.
  3. POST /session/:id/approve: a signature over session, runner, program hash, and the declared egress. Unanimous approval opens inputs.
  4. POST /session/:id/input, encrypted to the enclave. The last input starts the run, asynchronously.
  5. Poll GET /session/:id until COMPLETE or FAILED.
  6. POST /session/:id/output, signed: each party receives its own output, encrypted to its own key. Raw inputs were wiped after the run.

Runners are wasm, python -- source text, run by an interpreter baked into the measured image; the recommended path, because the reviewed artifact is the executed artifact -- and dobj. TEE_MODE is mock, dstack, gcp, aws, or nitro, and the client's policy is the same in every mode. From a node, operation-jc-box loads the jc-tee-vm client by name at first use and drives the session; its egress allowlist -- the hosts the program may reach -- is bound into every approval signature. The production box, its mode, and its measurement pins are what a node is set up with, in onboard/src/defaults.json; they change when the box is redeployed, so they are not repeated here.

What a joined node runs

pac join installs the plugin set in pac-ux/onboard/src/defaults.json, keyed by when each applies:

whenplugins
joinedtransport-iroh, discovery-directory
metarecords, results, sources, negotiation, jc-box-run, artifacts-git, packages-npm, operation-tsc, operation-llm, operation-jc-box, and @projectpac/flow-meta with sources: [skills, webExports, models, conversationNotes, agentHistory]
capturedsource-agent-history, source-conversation-notes
derivedsource-intents

The executor is @projectpac/executor-cli, the package source @projectpac/packages-npm, and the models pi or claude. The router is not in the set.

The flow

@projectpac/flow-meta is the one flow a joined node runs. A principal states an intent; the two nodes' agents, each seeing only the other side's descriptor, negotiate a design -- what the flow will do, which of each node's sources it may stake, what messages it exchanges, whether it computes in the box and what may come out -- and then the package that implements it: a small TypeScript plugin with no runtime dependencies, which both nodes install under one id and the initiator starts. The negotiation is the engine's rounds over an artifact store: one side suggests, the other reads the change and suggests back, until one agrees.

A draft is gated before it travels (src/gate.ts): the exact file set, an id derived from the task, the four named exports and no default, imports of node's builtins alone, syntax node's type stripper accepts, and -- where the node has operation-tsc -- a type check against src/library/pac.d.ts, the surface of a node as one file. What that file declares is exactly what a generated flow may rely on: self, identity, network, discovery, intelligence, api, trace, artifacts, negotiation, jcBox, jcBoxRun, and the five events. It does not declare records, results, or router. Both nodes run the gate again over the agreed bytes before installing, and exchange a hash of what they installed. Either half may decline, and the task ends declined with the reason: a first-class outcome, not a failure. Generated plugins are meta-<task>, package @projectpac-generated/meta-<task>, with POST /start and GET /status; they are installed by path, so a host that accepts only verified packages cannot run this flow.

@projectpac/flow-template is the copy-me: one file that does the smallest complete network thing and touches every seam a flow touches -- ctx.network.route first, an advertisement under SPOKEN_KIND retried until it sticks, a core/message handler, its own timers as effects of its scope, ctx.inject(["router"]) and ctx.inject(["results"]) from optional scopes so a node without either still runs it, ctx.api.handle for its routes, and one model run through startAndAppendTurn with composePrompt and interpret. skills, joint-search and objects are the hand-written flows meta now negotiates; objects is parked, because it needs an object daemon nothing here ships.

Not built yet

  • The data core plugin. core/data mounts, claims no service key, and does nothing (README). Sources and operations are plugins in pac-plugins, each claiming its own key.
  • Policy and authorization. No policy service exists; POLICY_DENIED is an error code with nothing that raises it. The one refusal made on a principal's behalf is operation-jc-box's egress allowlist.
  • One apply verb. Each operation adapter is its own verb behind its own key (ctx.jcBox, ctx.llm, ctx.tsc, ctx.pexe, ctx.dobj), deriving its caller; readUnderCaller is the bounded read of another plugin's files.
  • Sources into a generated flow. flow-meta copies the sources the design assigned to this node into <data dir>/<generated id>/sources/ before installing it and removes them at teardown (src/install.ts): the one documented place a plugin writes into another's folder, and the seam a data operation would replace.
  • Folder permissioning, and what a run's sandbox may see of the data directory.
  • The router in the joined set. It exists and is installable; flow-meta declares no intents to it.
  • Registry submissions, per-publisher credentials, and review: every entry is the operator's, published by walking checkouts.
  • Directory accounts and rate limits.
  • Sharing a trace with a peer. Checkpoints are signed; there is no export path.

Checked against main on 2026-09-12: pac-node, pac-plugins, pac-network, pac-router, pac-ux, pac-flow-meta, pac-flow-template, jc-tee-vm. What is meant: design. What to clone: repositories.