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:
| row | package | claims | state |
|---|---|---|---|
trace | @projectpac/core-trace | ctx.trace | built |
identity | @projectpac/core-identity | ctx.identity | built |
network | @projectpac/core-network | ctx.network | built |
discovery | @projectpac/core-discovery | ctx.discovery | built |
intelligence | @projectpac/core-intelligence | ctx.intelligence | built |
data | @projectpac/core-data | nothing | a stub: mounts, claims no service key, does nothing (README) |
host | @projectpac/core-host | ctx.host, and provides ctx.self | built |
api | @projectpac/core-api | ctx.api | built |
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. | contract | what it is |
|---|---|---|
identity | Identity | node id, identities, sign, verify |
network | Network | route, openSession, send, fetch, serve, closeSession |
discovery | Discovery | advertise (returns a disposer), query, adapter |
intelligence | Intelligence | startRun, appendTurn, startAndAppendTurn, closeRun, get |
host | Host | install, remove, enable, disable, kill, list, packageSource |
trace | Trace | append, query, checkpoint, reconstruct a session or a run |
api | ControlApi | handle, callRoute |
self | PluginBinding | the calling plugin's id, its default network identity node, its dir, log |
executors | Executors | the worker's registry -- provided in the worker process alone |
| event | dispatched by | when |
|---|---|---|
core/message | network | an envelope for one of this plugin's sessions arrived |
core/message-failed | network | an outbound message exhausted its retries |
core/turn-settled | intelligence | one settled turn, into the scope of the plugin that started the run, once |
core/removing | host | this plugin is being removed and is still itself -- the only exactly-once event |
core/peer-unreachable | network | between 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:
| method | path |
|---|---|
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.
| kind | package | claims | what |
|---|---|---|---|
| operation | operation-jc-box | ctx.jcBox | submit named sources to a verified box, encrypted; the party key is the adapter's |
| operation | operation-llm | ctx.llm | the python a box program needs to ask a model; makes no call itself |
| operation | operation-tsc | ctx.tsc | type-check TypeScript a flow was handed |
| operation | operation-pexe | ctx.pexe | compile an object-toolchain draft and build its box envelope |
| operation | operation-dobj | ctx.dobj | the write half of an object daemon |
| source | source-web-exports | ctx.webExports | data a service handed the principal back, with a sentence saying what is inside |
| source | source-skills | ctx.skills | procedures the principal wrote; the front matter kept apart from the body |
| source | source-llm | ctx.models | the principal's model access, offered like anything else they hold |
| source | source-agent-history | ctx.agentHistory | what they work on, read from their coding-agent transcripts by a model |
| source | source-conversation-notes | ctx.conversationNotes | their conversations, by way of files the assistant writes about itself |
| source | source-intents | ctx.intents | what they want done, extracted from the other sources |
| package-source | packages-npm, packages-git, packages-checkouts | -- | registered on ctx.host |
| executor | executor-cli | -- | mounted by the worker's executors.yml |
| transport | transport-iroh | -- | registered on ctx.network |
| flow-service | artifacts-git | ctx.artifacts | the versioned resource two peers negotiate over; git for history and diff |
| flow-service | negotiation | ctx.negotiation | the suggest-and-agree rounds, as a factory a flow drives with its own hands |
| flow-service | jc-box-run | ctx.jcBoxRun | the box session as durable steps: party, propose, view, approve, submit, fetch |
| flow-service | records | ctx.records | a plugin's durable rows, in its own folder, the caller derived |
| flow-service | results | ctx.results | what every flow produced, read in one place |
| flow-service | sources | ctx.sources | what this node holds, read in one place |
| -- | prompting | -- | functions, not a plugin: compose a prompt, interpret the answer |
| flow-service | router, in its own repository | ctx.router | a sentence decided into a declared intent and started through callRoute |
| discovery-adapter | discovery-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 directory | plugin registry | |
|---|---|---|
| package | @projectpac/node-directory | @projectpac/plugin-registry |
| holds | claims nodes make about themselves: presence, and advertisements | one operator's list of what a node could install, with a sentence per entry |
| who writes | any 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 |
| routes | PUT /nodes/:node, PUT /advertisements, POST /withdrawals, POST /queries, GET /health | GET /plugins, GET /plugins/:id/readme, PUT /plugins/:id, DELETE /plugins/:id, GET /health |
| lifetime | presence is a read-time filter: a node not heard from in presenceTtlMs (5 min) stops being returned; nothing is deleted | durable until removed; the catalogue is registered by walking checkouts' manifests, never committed |
| default port | 7460 (the lanes run it on 7451) | 7461 |
| production | node-directory.projectpac.net | plugin-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:
POST /sessionwith the program, the runner, and the participants' keys. The set and the runner are fixed here; the reply is the session id andsha256(program).- Each party reviews
GET /session/:id/programoffline. POST /session/:id/approve: a signature over session, runner, program hash, and the declared egress. Unanimous approval opens inputs.POST /session/:id/input, encrypted to the enclave. The last input starts the run, asynchronously.- Poll
GET /session/:iduntilCOMPLETEorFAILED. 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:
| when | plugins |
|---|---|
| joined | transport-iroh, discovery-directory |
| meta | records, 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] |
| captured | source-agent-history, source-conversation-notes |
| derived | source-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/datamounts, 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_DENIEDis an error code with nothing that raises it. The one refusal made on a principal's behalf isoperation-jc-box'segressallowlist. - One
applyverb. Each operation adapter is its own verb behind its own key (ctx.jcBox,ctx.llm,ctx.tsc,ctx.pexe,ctx.dobj), deriving its caller;readUnderCalleris 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.