How I separated the rules every agent shares from the places those agents run, so that one core serves a fleet of agents on a developer's PC and in the cloud.

Over the past months I have been building a system of AI agents. Each agent is a Claude Code plugin: a set of skills, hooks and commands. There are several different agents for different tasks. The agents are specialised, but they are not independent. They share a great deal of behaviour, and they have to run in two very different environments.

I package each agent as a Claude Code plugin, because plugins are the one built-in way to install and update a versioned bundle with one command across many projects. Plugins are optional. The same pattern applies if your skills, hooks and subagents live in a project's committed .claude/ directory or in ~/.claude/; only the way the core is distributed changes.

Two goals drove the design. The first was a shared base for all agents. Every agent needs context engineering, budget limits, model policy, telemetry, a catalog lookup for its own configuration and a handful of helper functions. In the first version each plugin carried its own copy of this code. Fixing a bug in context handling meant fixing it in six places. The base had to become one package that every agent uses and no agent reimplements.

The second goal was deployment independence. I use the agents locally in a terminal, I share them with other developers who install them as plugins, and I run the same agents headless in the cloud, where a driver starts a container per request and nobody is watching. The agent must behave the same in all three cases, and switching between them must not require touching agent logic.

Both goals point at the same fault line. The rules of the system (what an agent may do, which model it may call, how much budget it has, what a telemetry record looks like) were tangled with the details of the environment (file paths, how the model is invoked, how a follow-up agent is started, where settings live). Testing a rule needed a real machine with real tools and a real model endpoint, which is slow and costs money. When I listed what the rules actually needed from the environment, the list was short: read settings, store a record, ask a model, start a successor. That is the situation hexagonal architecture, also known as ports and adapters, was made for. Alistair Cockburn described the pattern in 2005 (original article). The rules form a core. The core defines how it is called and what it needs from outside; those definitions are ports. The pieces that satisfy a port in a specific environment are adapters. This article shows how the pattern applies to an agent system and which rules fell out of it.

Figure 1. The whole system. The Agent Core sits in the middle and knows neither agent names nor its host. Inbound ports (Hook, Skill, Command) are how the runtime and the user trigger it; outbound ports are what it asks of its environment. Each outbound port forks into a PC adapter and a cloud adapter. Dependencies point inward: the core defines the ports, the adapters conform.

Figure 1. The whole system. The Agent Core sits in the middle and knows neither agent names nor its host. Inbound ports (Hook, Skill, Command) are how the runtime and the user trigger it; outbound ports are what it asks of its environment. Each outbound port forks into a PC adapter and a cloud adapter. Dependencies point inward: the core defines the ports, the adapters conform.

The core: one base for every agent

The core is a single, small package. It holds everything that every agent needs and nothing that only one agent needs: context engineering (what goes into the prompt, how transcripts are compacted, how prior results are carried forward), the guards that check which agent may use which model, the budget limits per task, the evaluation of the agent catalog, and the schema of the telemetry record every run leaves behind. It also holds the shared skills and hooks as reusable building blocks, so an agent plugin composes them instead of copying them.

Four rules apply to the core. It uses only the standard library of its language. It knows no agent names; what a specific agent is allowed to do is data, read through a port. It does not know whether it runs on a PC or in the cloud. And it never touches files, network, model or processes directly; every such access goes through a port. The last rule is what makes the core testable without a machine or a model endpoint: every test replaces the adapters with fakes and runs in milliseconds.

Figure 2. The shared base. Six agent plugins draw on one core for context engineering, model policy, budgets, catalog and telemetry. A plugin keeps only the behaviour that is specific to its agent, such as how it steers its sub-agents.

Figure 2. The shared base. Six agent plugins draw on one core for context engineering, model policy, budgets, catalog and telemetry. A plugin keeps only the behaviour that is specific to its agent, such as how it steers its sub-agents.

Ports: the core's contract with the world

A port is a function signature the core defines. Inbound ports describe how the environment calls the core; there are three. A hook is an event of the agent runtime, for example the moment before a tool call, and runs automatically. A skill is an instruction set the agent loads on demand. A command is text the user types. Outbound ports describe what the core needs from outside: the project root, the storage folder, the settings, the catalog row for an agent, a model answer, the start of a successor, the storing of a telemetry record. My system has about a dozen outbound functions.

Ports follow a few rules. Each port is defined exactly once, in the core. A port is narrow: one function, few parameters, simple return values. The number of ports stays small, and a new port needs a reason. Every port has an error contract. Ports in the time-critical path of an event never throw; they return a default or nothing, because a hook that fails must not break the agent's turn. Ports where the caller has to make a decision, such as the model answer, throw a named exception so the caller can react.

Figure 3. Ports and their error contracts. Three inbound ports, about a dozen outbound ports, each defined once in the core. A port in the event path never throws; a port where the caller must decide throws a named exception.

Figure 3. Ports and their error contracts. Three inbound ports, about a dozen outbound ports, each defined once in the core. A port in the event path never throws; a port where the caller must decide throws a named exception.

Wiring: how a port is connected and who serves it

The connection style is chosen per port. When caller and core live in the same process, the port is a plain function or interface; an import is enough, and in tests a fake function replaces the adapter. An HTTP API is used only at a process or network boundary, for example between the request arriving from outside and the driver that starts a run. Inside the agent process there is no API, because no secret may live there and an event must never wait on the network. When no immediate answer is needed, a file is enough: the core writes it, another process reads it later. The successor request in the cloud works this way.

Which adapter serves a port is decided at start-up. For most ports the process environment is sufficient: environment variables that the driver or the runtime sets. For ports with several adapters the core keeps a registry. An adapter registers under a name at start-up, the selection names it, and the lookup is resolved on first call. An unknown name is a start-up error, not a runtime surprise. There is no global switch labelled PC or cloud; each port is bound individually. That is what makes the third deployment target, the agent on a colleague's machine, free: it is the PC binding with a different project root.

Figure 4. Wiring. A port is a plain function inside one process, an HTTP API only at an existing process or network boundary, and a file when no immediate answer is needed. Adapters register by name; the environment selects one per port.

Figure 4. Wiring. A port is a plain function inside one process, an HTTP API only at an existing process or network boundary, and a file when no immediate answer is needed. Adapters register by name; the environment selects one per port.

Adapters: the same port on a PC and in the cloud

An adapter satisfies a port for one environment. On the PC, the storage adapter uses the project folder, the model adapter calls a command-line tool, the successor adapter opens a new terminal window, and the telemetry adapter appends a line to a local file. In the cloud, storage is the working folder of the run, the model is reached through an API client, the successor is a follow-up job the driver creates from a request file after the process ends, and the driver copies telemetry into central logging.

Writing this down exposed something useful: most ports have the same code on PC and in the cloud. For those there is one adapter, parameterised through environment variables. Only two ports needed a genuine second adapter, the successor and the telemetry sink. That is the practical shape of deployment independence. It is not two parallel implementations; it is one implementation with two small forks.

Adapters follow their own rules. An adapter lives outside the core, and the core never imports it. It satisfies the signature and the error contract of its port exactly. Every port has a contract test that runs against all adapters of that port; an adapter is done when it passes that test without a special case.

Here is the telemetry sink, one of the two ports with a real second adapter, and its contract test. The sketch is in Python; the names are illustrative.

# core/ports.py: the port, defined once in the core. from typing import Protocol class TelemetrySink(Protocol): def store_record(self, record: dict) -> None: """Store one telemetry record. Error contract: this port is in the event path of a hook, so it never raises. On failure it drops the record. """ # tests/contract/test_telemetry_sink.py: one test, every adapter. import pytest from adapters.pc import LocalFileTelemetry # appends to a local file from adapters.cloud import RunFolderTelemetry # writes to the run folder from tests.fakes import InMemoryTelemetry # used by core tests  ADAPTERS = [LocalFileTelemetry, RunFolderTelemetry, InMemoryTelemetry] @pytest.fixture(params=ADAPTERS) def make_sink(request): return request.param def test_stores_a_record(make_sink, tmp_path): sink = make_sink(root=tmp_path) assert sink.store_record({"agent": "example", "tokens": 1200}) is None def test_never_raises_on_a_record_it_cannot_write(make_sink, tmp_path): sink = make_sink(root=tmp_path) sink.store_record({"agent": "example", "payload": object()}) def test_never_raises_when_the_root_is_missing(make_sink, tmp_path): sink = make_sink(root=tmp_path / "missing") sink.store_record({"agent": "example"}) 

Figure 5. Adapters under contract. Only two ports needed a second real adapter for the cloud. Every adapter of a port, including the test adapter, must pass the same contract test without a special case.

Figure 5. Adapters under contract. Only two ports needed a second real adapter for the cloud. Every adapter of a port, including the test adapter, must pass the same contract test without a special case.

Three deployment targets, one core

This is where the two goals meet. Because the shared base is one package and because every environment detail sits behind a port, the same agent runs in three places without any change to its logic.

Local. I run the agent in a terminal. The runtime fires hooks, I type commands, the PC adapters bind by default. Shared. A colleague installs the agent, as a plugin or by cloning a repo with a committed .claude/ directory. Nothing changes except the project root and the settings file; the PC adapters read both from the environment. Cloud. A request arrives at an HTTP edge, the driver starts a container, sets the environment variables that bind the cloud adapters, runs the agent headless, and after the process ends picks up the successor request file and the telemetry. The core cannot tell the three apart, and that is the point.

Figure 6. Deployment independence. Local use, a shared install and a cloud run all execute the same core and agents. Local and shared differ only in the project root; the cloud swaps in two adapters and a driver.

Figure 6. Deployment independence. Local use, a shared install and a cloud run all execute the same core and agents. Local and shared differ only in the project root; the cloud swaps in two adapters and a driver.

Guards are core policy

A guard decides whether an agent may take an action: push to git, dump a large file into its context, leave its worktree, ask the user a question in an unattended run, run past its context budget. In the first version each guard was a Claude Code hook script that parsed Claude's hook format and made the decision in the same file. The decisions now live in the core as policies. Per vendor, a thin hook adapter translates the hook event into a neutral action (a shell command, an edit, a question, a stop) and renders the core's decision back into the vendor's format. Claude Code and OpenAI Codex get the same rules from the same code, and one shared contract test suite checks that both adapters block and allow the same cases. Guards for tools that only Claude Code has, such as starting a sub-agent, apply only there.

Two rules protect the guards themselves. The agent cannot edit its own settings, hooks or guard code; an edit or a shell command that targets those files is denied, and only a human can change them. And an unattended run does not ask a human. When a guard blocks an irreversible step, such as a git push, the agent leaves the step out, the core records it, and the list of skipped steps is reported at the end of the run.

For Codex the hooks are not the only layer. Codex hooks are not a complete enforcement boundary: a tool the runtime does not report to the hook, or a command wrapped in another interpreter, can pass unseen. The Codex agent adapter therefore always starts codex exec in the sandbox mode workspace-write with the approval policy never. The sandbox limits writes to the workspace, and the run never stops to wait for an approval. The guards are the first layer, the sandbox is the second.

Extending the system without touching the core

A new adapter, for example for a locally hosted model, needs no change to the core. I write the function, let it pass the port's contract test, register it under a name at start-up, and set the selection in the environment. The core knows only the name.

A new vendor works the same way. The vendor sits behind two outbound ports, the text port for a single model answer and the agent port for a whole agent run, and both swap by adapter. The guards stay where they are, because they are core policy. A new vendor therefore needs a hook adapter and an agent adapter, and both must pass the shared contract tests. For Codex the agent adapter starts codex exec. One piece of vendor data does enter the core: the paths of the vendor's own settings and hook files, so that the guard against self-editing covers them too. No rule changes.

A new agent is also no change to the core. It arrives as a row in the catalog: name, permitted model, budget, return limit. The core reads the row through the catalog port and applies its guards. Behaviour that concerns only this agent, such as how it steers its sub-agents, stays in the agent's plugin and never enters the core. In practice this has made adding an agent an afternoon's work: a plugin that composes the shared skills and hooks, plus one catalog row.

The two goals I started with turned out to be one. A shared base is only shareable if it does not know where it runs, and deployment independence is only cheap if the environment-specific code is confined to a few small adapters. Hexagonal architecture gave both a place to live.

Figure 7. Extension points. A new adapter registers by name and passes the contract test; a new agent is a catalog row. Neither touches the core.

Figure 7. Extension points. A new adapter registers by name and passes the contract test; a new agent is a catalog row. Neither touches the core.

Originally published at https://codespaces.li/blog/hexagonal-agent-architecture/