Status: verified live on one host, under the scope below. A fresh VM, set up
by provision-guest.sh and bring-up, has passed every check
docs/POC-REPORT.md records:
- the proof of concept (L1);
- the full bring-up with this repository's tooling (L2);
- the rerun with the mounts interceptor on (L3's precondition, 2026-10-05).
Site: the explainer
(docs/index.html) walks through AMAP on OpenShell with animated flows.
GitHub Pages publishes it from docs/.
This repo deploys AMAP (the Agent Mailbox Access Protocol, specified in amap-spec) on hosts that run NVIDIA OpenShell (Apache-2.0).
It is the OpenShell sibling of amap-deploy-sandy. That repo's own README states the premise: sandy is "one isolation choice, not a requirement", and any host that provides credential, network and filesystem isolation can replace it, "with a deployment of its own in place of this repo". This is that deployment.
One OpenShell host, one operator. The router, the connector's payload and every sandbox run on that host, and everything that can call its gateway acts for that operator. The repository protects agents from the host and from each other. It does not protect the host from its operator. Running several operators, or a fleet of hosts, needs the work listed in DESIGN.md "Widening the scope" first.
- amap-connector-claude, unmodified. It provides the two MCP servers the agent talks to and the delivery daemon that runs beside the session.
- amap-router-local, unmodified. It runs in its own container on the host and has no network.
- The wire contract. Nothing OpenShell-specific enters
amap-spec.
What changes is only this: how each sandbox gets its lanes, its mounts, its identity, its MCP configuration and its delivery daemon. OpenShell does that instead of sandy.
- Linux 6.2 or later (Landlock ABI 3) with Docker. Docker is the only compute driver (D3).
- OpenShell
v0.1.2or later, with the gateway configured asgateway-configprints (D6, DESIGN.md section 6). - Three sibling checkouts, each beside an ancestor of this repository or
named by its variable:
amap-router-local($AMAP_ROUTER_REPO),amap-connector-claude($AMAP_CONNECTOR_REPO) andamap-deploy-sandy($AMAP_SANDY_REPO). The last is not optional: this repository imports the pure core of sandy'sfleet_policy.py(D1), so every verb needs it.$AMAP_SPEC_DIRis needed only by the tests and by the L1 document checks. - The mounts interceptor, running and registered in
gateway.toml(docs/INTERCEPTOR.md). On the example VM,examples/vms/proxmox/provision-guest.sh --home "$AMAP_OPENSHELL_HOME" --applydeploys it. - Python 3.9 or later, standard library only. pytest for the tests.
- A Console API key for this deployment's provider profile
amap-claude-code, exported asANTHROPIC_API_KEYin the terminal that provisions (D10). - The Claude Code version you have measured. There is no default.
Everything lives under $AMAP_OPENSHELL_HOME. install --apply and
provision-guest.sh --home DIR --apply record that path in
${XDG_CONFIG_HOME:-$HOME/.config}/amap-openshell/home. After that,
amap-openshell.py finds the home without --home or the variable, in any
shell or over plain ssh (D25). Order: --home, then
$AMAP_OPENSHELL_HOME, then the record. teardown --apply removes the record
when it names the home torn down.
| Path | What it is | Whose |
|---|---|---|
$AMAP_OPENSHELL_HOME/fleet.json |
the fleet policy: fleet_domain, groups, default_peers, peers, task_graph, task_deny, plus this deployment's workspace |
seeded by install from --fleet, with fleet_domain openshell.<host>.<base> (D21); then yours to edit, never rewritten |
$AMAP_OPENSHELL_HOME/payload |
the connector's binaries, the main-process wrapper, the session lister, the seeder, mcp-servers.json, claude-settings.json, INBOX-POLICY.md. Mounted read-only at /opt/amap/payload |
install |
$AMAP_OPENSHELL_HOME/roster |
the roster the router writes. Mounted read-only at /opt/amap/roster |
created empty by install, written by the router |
$AMAP_OPENSHELL_HOME/instances/<name>/inbox, peer, outbox |
the member's lanes, under the lanes directory in the sandbox. inbox and peer read-only, outbox read-write |
provision |
$AMAP_OPENSHELL_HOME/policies/<name>.yaml |
the sandbox's OpenShell policy | provision |
$AMAP_OPENSHELL_HOME/commands/create-<name>.sh |
the openshell sandbox create argument vector, as run |
provision |
$AMAP_OPENSHELL_HOME/membership.json |
(workspace, name, id) for each member, the ID read back from sandbox get --output json |
provision, deprovision |
$AMAP_OPENSHELL_HOME/selected.json |
the router's discovery verdict (D2) | written once every member is recorded |
$AMAP_OPENSHELL_HOME/router.json |
the router's config, generated; never hand-edit | same |
$AMAP_OPENSHELL_HOME/router-state |
the router's private state, including each instance's first sight | same |
$AMAP_OPENSHELL_HOME/providers/amap-claude-code.json |
the provider profile with the image's real binary paths | l1-kit profile |
$AMAP_OPENSHELL_HOME/gateway-fragment.toml |
the gateway fragment, for reference | l1-kit prepare (gateway-config prints the same text) |
Nothing is written inside a sandbox. $AMAP_OPENSHELL_HOME must not be
inside, or contain, this repository or any sibling checkout.
Every command runs from this repository's root, after siblings --apply. Set
AMAP_OPENSHELL_HOME and CLAUDE_CODE_VERSION, or pass --home and
--claude-code-version. The provider key is ANTHROPIC_API_KEY, or one line on
stdin with --api-key-stdin.
python3 amap-openshell.py --home "$AMAP_OPENSHELL_HOME" --fleet examples/fleet.json bring-up --claude-code-version "$CLAUDE_CODE_VERSION"
python3 amap-openshell.py --home "$AMAP_OPENSHELL_HOME" --fleet examples/fleet.json bring-up --claude-code-version "$CLAUDE_CODE_VERSION" --applyThe first is a dry run: it reads the host and prints each stage's plan, and
which stages it would skip and why. The second runs the lines below in order and
stops at the first that fails. Each stage skips what is done: an image labelled
with this version and uid:gid, an identical profile, a member that is present
and recorded, a running router. bring-up never deletes a sandbox, a container,
an image or a profile, never edits gateway.toml and never restarts the
gateway, and it refuses a router container that exists but is not running.
Preflight also refuses when the mounts interceptor is not accepting connections
on its socket. It names examples/vms/proxmox/provision-guest.sh, which on the
example VM does the gateway.toml merge and the gateway restart that
bring-up leaves alone. The
key is needed only to create a member, reaches sandbox create and nothing
else, and is never written or printed. The last line is AMAP is up only when
verify exits 0. Otherwise it names the stage that stopped it, and the exit
code is not 0.
Set the variables first (AMAP_OPENSHELL_HOME, the three sibling variables if
the checkouts are not beside this one, CLAUDE_CODE_VERSION, and
ANTHROPIC_API_KEY).
docker build --build-arg CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" --build-arg SANDBOX_UID="$(id -u)" --build-arg SANDBOX_GID="$(id -g)" -t amap-openshell-agent image/
python3 amap-openshell.py gateway-config
python3 amap-openshell.py install --apply
python3 amap-openshell.py l1-kit profile --home "$AMAP_OPENSHELL_HOME" --binary "<node path>" --binary "<claude path>" --apply
python3 amap-openshell.py provision alpha --apply
python3 amap-openshell.py provision beta --apply
"$AMAP_ROUTER_REPO/docker/build.sh"
"$AMAP_ROUTER_REPO/docker/run.sh" --config "$AMAP_OPENSHELL_HOME/router.json" --detach
python3 amap-openshell.py verifybring-up also labels the image with the version and the uid:gid, so that it
can tell a later run to reuse it. The gateway merge (TUTORIAL step 4) stays by
hand.
The full walk-through, with the gateway merge, the provider import and the experiments, is docs/TUTORIAL.md.
Host-wide options (--home, --fleet, --image, --run-as,
--restart-policy, --openshell, --gateway-toml) go before the verb.
Every writing verb is a dry run without --apply. verify reports PASS, FAIL
or UNKNOWN, and UNKNOWN is a problem, never a pass: it exits 1. list and
router-config exit 1 when something is not clean.
| Verb | What it does |
|---|---|
install |
write the payload, the fleet.json template and the roster directory; router.json once every member is recorded. A new fleet.json gets fleet_domain openshell.<host>.<base>; --fleet-domain-base sets the base |
provision <name> |
create the sandbox's lanes, policy and command, create the sandbox, record its ID in membership.json |
deprovision <name> |
delete the sandbox, empty its lanes and de-enrol it |
verify |
report PASS, FAIL or UNKNOWN, including the in-sandbox write attempt |
list |
list the fleet's sandboxes against membership.json |
router-config |
render the router's config; reports drift |
gateway-config |
print the gateway fragment and compare it with gateway.toml (read-only) |
teardown |
remove what install wrote |
bring-up |
build the image, check the gateway config, install, import the provider profile, provision every member, render the router config and start the router, then verify. A dry run without --apply, rerunnable; the last line is AMAP is up only when verify passed |
siblings |
clone or fetch the sibling checkouts beside this repository and check out the pins in siblings.json; amap-spec is cloned or fast-forwarded |
l1-kit |
proof-of-concept tooling: write the host-side files for L1 (prepare, record, profile). Also renders the provider profile used above |
l1-run |
proof-of-concept tooling: run docs/L1-RUNBOOK.md steps 0 to 9 on this host |
- A member joins. Add it to
fleet.json, theninstall --apply, thenprovision <name> --apply. - A member leaves.
deprovision <name> --apply, remove it fromfleet.json, thenrouter-config --apply.deprovisionempties that member's lanes and removes its first-seen marker underrouter-state, so a recreated name is a new instance with a fresh first sight. - Who may task whom changes. Edit
task_graphinfleet.json, thenrouter-config --apply. Never editrouter.json.
- One name. The OpenShell sandbox name is the router instance and the local
part of the address (
<name>@<fleet_domain>).fleet_domainisopenshell.<host>.internalunless you chose otherwise (D21). - The identity is the pair (name, OpenShell ID). A deleted and recreated sandbox is a different member.
- The boundary is the read-only mount and the Landlock entry. A write to a
read-only lane gives
EROFS(docs/POC-REPORT.md, Unknown 2). - Authorisation is the router's. The roster lists who exists, never who may task whom.
- Never create a lane or a sandbox directory by hand.
provisiondoes. Dry run is the default.
OpenShell contains the agent: kernel-level filesystem and process confinement, a default-deny network and placeholder credentials. It deliberately leaves agent-to-agent communication, per-agent identity and fleet governance out of scope. AMAP covers exactly that gap, at the message level, with an open, testable contract.
In one line: OpenShell contains the agent; AMAP governs what it says and to whom.
| File | Contents |
|---|---|
| docs/TUTORIAL.md | The operator's runbook: bring-up, verify, a first delegation, and "Break it on purpose" |
| docs/L1-RUNBOOK.md | The proof-of-concept procedure, with every OpenShell command cited |
| docs/POC-REPORT.md | The result of the live proof of concept |
| providers/README.md | The provider profile this deployment imports |
| image/Dockerfile | The sandbox image recipe |
| siblings.json | The sibling checkouts and the commit each is pinned to (amap-spec is not pinned) |
| examples/fleet.json | The example fleet policy: alpha may task beta |
| examples/vms/proxmox | A guest-provisioning example for a Proxmox host |
| DESIGN.md | The five properties AMAP needs from a host, how sandy provides each, and the proposed OpenShell mechanism for each, with every assumption marked |
| OPEN-QUESTIONS.md | The questions about OpenShell's code and docs that the design depended on, each answered in phase 0 |
| FINDINGS.md | Phase 0 results: the answer status of each question, the design changes they caused, and whether phase 1 can go ahead |
| IMPLEMENTATION-PLAN.md | Phases 1 and 2 as steps for a plan, implement and validate pipeline, with gates, live stops and acceptance criteria |
| PLAN.md | Phased work: a manual proof of concept, then tooling, then optional hooks and Kubernetes support |
| CLAUDE.md | Standing instructions for the agent that picks this up |
python3 -m pytest tests -qThe suite checks against four read-only sources: amap-router-local
($AMAP_ROUTER_REPO), amap-connector-claude ($AMAP_CONNECTOR_REPO),
amap-deploy-sandy ($AMAP_SANDY_REPO) and amap-spec ($AMAP_SPEC_DIR). A
missing source fails the run. It never passes as a smaller set of tests. The
suite never runs OpenShell, Docker or the router.
python3 amap-openshell.py siblings --apply sets them up beside this repository.
The gRPC tests of the interceptor run in its hash-locked virtualenv, not in the
suite above: interceptor/tests/conftest.py gives the command. CI's
interceptor job runs them.
Apache License 2.0. See LICENSE, and NOTICE for the third-party and derived
material. SECURITY.md says how to report a vulnerability, and
CONTRIBUTING.md how to contribute.