Skip to content

Architecture decisions

The dependency-free contracts package, the typed wire protocol, and the licence.

13 min readUpdated 13 Sept 2026Reviewed 12 Sept 2026Published 12 Sept 2026/docs/internals/architecture

Three decisions explain most of the architecture. None of them are clever: each one closes a specific failure mode that the alternative leaves open.

Decision 1: the shared contracts package is dependency-free

The contracts package holds the vocabulary and nothing else: the org chart, the employees, the model catalog and its policies, the run record, the skill documents, the plugin manifest, the office blocks, the style presets, and the wire protocol. Nine source modules. No network, no filesystem, no database driver, no React, no three.js.

The evidence is in its manifest, and it is unusually literal: the package declares no dependencies at all. Its only development dependency is TypeScript. The modules import each other by relative path, and the only Node built-in imports anywhere in the package are node:test and node:assert in its own test file. You can confirm that in about ten seconds from a checkout:

cat packages/core/package.json

Being dependency-free is what makes the rest work. The orchestrator and the browser both compile against exactly these types, which means a change to the shape of a run is a type error on both sides rather than a runtime surprise on one of them. That is not a convention anyone has to remember: it is what the typecheck enforces on every change.

The failure mode this closes

The moment the contracts package imports a database driver or a UI library, it can only be consumed by whichever side already has that dependency. The honest shared vocabulary quietly becomes one side's internal model, with the other side importing it and inheriting a dependency it should never have had. That process is gradual and always looks reasonable at each step, which is exactly why the rule is a hard zero rather than a budget.

What it does contain, and what it must never contain

Dependency-free is not the same as types-only, and the distinction is deliberate. The package carries pure helpers wherever both sides have to agree on the same answer: the tier order and its rank lookup, the two functions that apply a plugin's routing hints, the style-preset parsing and resolution, and the labels a style editor renders. None of them has an effect. Each is a pure function of its arguments, which is what lets both sides run the same code instead of reimplementing the same rule twice and diverging on the third edit.

The line the package must never cross is anything with an effect: no reading a file, no opening a socket, no clock, no randomised identifier, no logging, no rendering. That is the whole test, and it is easy to apply to a proposed addition. If the code can return a different answer on the same input, or can fail for a reason outside its arguments, it belongs on one side of the wire rather than in the shared vocabulary.

The cost of the rule is small but real. Anything genuinely shared has to be expressible as data plus pure functions, so a few things that would be convenient to share are simply duplicated instead, and a change to the wire protocol has to be made in one file that both sides then recompile against. Both are the point: the friction is concentrated at exactly the moment a shape changes, which is the moment a mistake is cheapest to catch.

The shipped layout

This is the real tree, read from the repository rather than from memory:

packages/core        the shared vocabulary: model, org, run, events, plugin, skill, block, style
apps/server          the orchestrator
  engine/            complexity, prompt building, one turn, stage modes, the run engine
  llm/               provider adapters, catalog, discovery, quality, health, benchmarks, pricing
  router/            the tier walk and the weighted score
  org/               the shipped company and the shipped pipelines
  tools/             fifteen built-in tools, one workspace resolver
  security/          the address guard, the child-environment scrub, control-sequence stripping, tree kill
  mcp/               the MCP client: stdio and Streamable HTTP, config, tool publishing
  vendors/           third-party agent harnesses: command and ACP transports, read-only enforcement
  memory/            the fact ledger and its full-text index, in the store
  skills/            the markdown loader and the per-turn selector
  plugins/           manifest validation, the plugin host, panels, install bundles
  office/            the block kit and the floor layout
  store/             SQLite, with a non-persistent fallback
  server/            the runtime: persist, project, broadcast
  index.ts           boot, the HTTP surface, the WebSocket
apps/web             the office and the console
  src/app/           the client store, the socket client, formatting, status vocabulary
  src/office/        the three.js canvas, avatars, anchors, floors, theme
  src/console/       panels, pages, transcript, activity feed, plugin screens
blender/scripts      the asset pipeline: shell, block kit, furniture, export, verifier
blender/reference    the hand-authored office the block kit is measured against
skills/              fifteen skill documents, loaded at boot
plugins/             three shipped plugins: cost-guard, local-coder, office-echo

Two details in that tree are load-bearing. The engine directory is a chain of small modules rather than one large one, because the run engine is where the failure policy lives: a budget halt, an optional stage, a cancelled stage and a stage that produced nothing are four distinct facts and each has one place to be decided. And the tools directory holds paths.ts, a single resolver module that the filesystem tools share — which is the mechanism the next section of Known gaps turns out to have a hole in.

Decision 2: the wire protocol is shared types

There is one WebSocket, typed end to end, and every message in both directions is declared in the contracts package. The real counts are 31 server events and 33 client commands. The earlier figures of 24 events and 14 commands, and then 27 and 31, were each wrong when written; counting the members of the two unions is a two-minute job.

On connect the server sends the whole office state in a single event, so a console never has to reconstruct the world from a sequence of deltas. Full snapshots are used elsewhere too: an office update replaces state wholesale, and a plugin-state change broadcasts the entire plugin system state rather than a patch. The bias is deliberate. A delta protocol is smaller on the wire and much harder to get right after a reconnect, and an office console is not bandwidth-constrained.

What is actually on the wire

The vocabulary is worth seeing as a whole, because its balance is the architecture in miniature. Grouped by concern, the 31 server events are:

  • Lifecycle (5): the hello that carries the whole office, office state replaced, an organisation chart change, installation settings, and the plugin system state.
  • Run skeleton (4): a run created, a run updated, a stage started, a stage finished.
  • Turns (4): a turn started, a text delta, a reasoning delta, and the finished turn record.
  • People (3): an employee updated, an employee moved seat, and a speech.
  • Work (4): a tool result, an artifact created, an approval requested, an approval decided.
  • Conversation (2): a direct message exchange, and the answer to a planning turn.
  • Accounting (3): a budget update, a routing decision, and lifetime usage attributed to an employee.
  • Diagnostics (2): a log line, and an error.

And the 33 client commands, similarly grouped: control and conversation (submit, cancel, chat, plan, approve, resync, ping); floors (select one, create one, remove one, set its skills, its budget, its details and its style, add a room, remove a room); the org chart (set a model policy, set role grants, set a seat, hire, fire, set the routing posture); plugins (enable or disable, configure, refresh, install, remove, add a source, remove a source); memory (write a fact, retract one); installation settings; and loading a historical run.

The shape of those lists is the point. Almost everything a person can do in the console is a first-class command on the socket, which is why the interface can be replaced, scripted or driven from a terminal without touching the server. The events outnumber the commands because the server narrates the work: every stage, turn, delta, speech, tool call, artifact and routing decision is its own message rather than a status field someone polls.

One ordering rule holds the whole thing together, and it lives in a single place: the runtime is the only writer, and it persists an event, updates its own durable projections, and only then broadcasts. Persistence and broadcast therefore cannot drift apart, and the property that follows — a reconnecting client sees a superset of what it had, never a gap — is a consequence of that ordering rather than a promise anyone has to remember.

The mechanism that stops an event being silently dropped

The payoff is in the client store, which implements every server event in one switch statement and ends with a default branch that assigns the event to a variable typed never. If a future server event variant is added and nobody handles it, that assignment stops compiling. A new event therefore cannot be silently ignored; it fails the typecheck instead.

At runtime the same branch is tolerant rather than fatal: an unknown frame is ignored and reported as a line in the activity feed, on the grounds that a console which crashes on a frame it does not recognise is worse than one that says it saw something new. Types prevent the omission; the fallback prevents the crash.

That mechanism is itself tested. The web harness drives the real store with one synthetic frame per server-event variant and asserts, among other things, that every event was counted — so adding a variant to the protocol without teaching the store about it fails the harness as well as the typecheck. See Verification for the counts.

The console is an ordinary client

The interface is not privileged. It speaks the same protocol a script would, which is why the whole office can be driven from a terminal:

curl -s localhost:8787/api/health

curl -s -X POST localhost:8787/api/submit \
  -H 'content-type: application/json' \
  -d '{"brief":"..."}'

curl -s localhost:8787/api/runs/<runId>

The HTTP surface is separate from the socket and much larger than the handful of routes older documentation listed: 40 route handler conditions — 28 static paths and 8 parameterised matchers, resolving to 36 distinct path patterns and 48 distinct method-and-path operations. The split is not arbitrary. Requests that change the office — submitting work, editing the org chart, choosing a floor, answering an approval — go over the socket, because they need to be answered on the connection that will receive the resulting events. One correction that follows from this: cancellation is a socket command, not an HTTP route. Posting to a cancel URL does not exist and answers with the catch-all 404.

Two counting traps are worth naming, because both have produced wrong numbers in this project's own documentation. The socket declares 33 client commands but serves 26 of them, because the plugin-management commands and several others are reached over HTTP rather than the socket. And one declared server event, memory.updated, is never emitted at all: memory changes ride memory.created and memory.retracted, so the union is four events larger than the set a running office can actually send.

Nothing on that socket authenticates. There is no user, no session and no token. The default bind address is loopback for that reason, and it is the reason to keep it there. See Known gaps for what an unauthenticated socket can reach, and Environment for the bind settings.

What shared types cannot prove

Types guarantee that both sides agree about the shape of a message. They guarantee nothing about its meaning. A client can still receive a correctly typed office update and render it wrongly; a server can emit a correctly typed turn record with an empty work product. Everything semantic about the protocol is either asserted by the protocol smoke test or it is not asserted at all, and the verification page is where that distinction is drawn.

Decision 3: the AGPL, deliberately

The licence was chosen because of what this project is: software designed to be operated as a server. A permissive licence would let someone host a modified dev3d as a service and never publish the changes, and an office that grows a plugin marketplace is exactly the kind of thing people run for other people. That is not a hypothetical failure mode; it is the most likely way a modified version would be distributed.

The AGPL's network clause closes that specific gap: if you run a modified version and let other people use it over a network, you offer them its source. The project is licensed AGPL-3.0-or-later. That is a deliberate constraint on hosting a modified version, not an accident of picking a licence file, and the licence summary states what it asks of you in practice.

One detail worth knowing before redistributing: the LICENSE file at the repository root is the verbatim GNU Affero General Public License, version 3, and it carries no project copyright line. The only notice in it is the Free Software Foundation's own. Source files do not carry per-file headers either. That is normal for a young project and it is not a licence defect — the grant is the same — but if your process expects a copyright holder to be named in the file, you will not find one there.

Plugins are separate work

A plugin is your own work. Loading one into the orchestrator does not make it a derivative of the orchestrator, which is the same line the Free Software Foundation draws for GPL-covered programs and their plugins. The architecture supports that position rather than merely asserting it:

  • The plugin host passes data, not code, across the boundary wherever it can. A declarative plugin is a manifest: providers, models, skills, role templates, pipelines, routing rules and panels, all as validated values.
  • A panel is a closed set of widget kinds — metric, key value, table, list, bars and note — rather than embedded markup or an iframe, so a plugin never ships anything that runs in the browser.
  • The manifest declares a plugin API version so that a host can refuse a manifest written against a different contract.
  • A plugin identifier must be reverse-DNS style with at least two segments, which makes collisions between two authors' plugin sets a validation error rather than a silent override.

A code plugin is a different proposition, and the difference is stated on the page that documents plugins rather than buried here: it runs in the orchestrator's process with the orchestrator's authority. Known gaps says what that means for confinement.

What the licence does not decide

It does not decide the licence of the plugins you write, the terms of the marketplace listings on this site, or the licence of a model you route to. It does not prevent you from selling access to an unmodified dev3d, and it does not require you to publish your prompts, your org chart or your workspace. It requires one specific thing of one specific case: modifications, offered to other people over a network, with their source offered too.

In practice, that sorts the common situations cleanly:

  • Running it unmodified, for yourself or for a team, including commercially: nothing is asked of you beyond keeping the licence and notices intact.
  • Modifying it for private use: nothing is asked of you, as long as other people are not using the modified version over a network.
  • Hosting a modified version for other people: you offer them the source of the version they are using. This is the clause the licence was chosen for.
  • Writing a plugin: your work, your licence. The data-only contribution boundary is what keeps that question simple.
  • Shipping it inside something else: the covered code travels with its licence, which is true of any copyleft.

That is a summary of the licence's intent and not legal advice; the licence text in the repository and the licence summary are the authority, and a lawyer is the authority after that.

How to audit these three decisions

Each decision leaves a file you can read and a symptom you would see if it had been violated:

DecisionWhere to lookWhat a violation looks like
The contracts package is dependency-free The package manifest and the nine modules under its source directory A dependencies entry, a node: import outside a test, or an import of anything under apps/
The protocol is shared types The two unions in the events module, and the client store's exhaustive default branch An event handled with a string literal cast, or a client that filters frames it does not know about without reporting them
The AGPL, and a data-only plugin boundary The licence file, the plugin manifest validator, and the panel widget renderer Manifest fields that carry executable markup, or a rendering path that evaluates plugin-supplied script in the browser

If you are auditing for trust rather than curiosity, read those three before anything else, and then read Known gaps, which is where the same architecture admits what it does not protect you from.

Linked from

Did this page answer your question?