Agent VMs: the L3 helpers over the primitives
Status: specified and built 2026-09-10. This document is the specification the code in
microvms-core/src/agents/ and the agent-up / agent-prompt commands implement, and
the record of the scope decision it changed.
The three layers, named
Section titled “The three layers, named”The repository has always had two layers and never named them. This document names three.
L1, the primitives. The daemon’s wire routes (exec, fs, health, the lifecycle
hooks) and the control-plane operations (CreateMicrovmImage, RunMicrovm, suspend,
resume, terminate, the auth-token mint). Every one is generic, measured against the real
service, and closed against the platform’s traps. docs/PROTOCOL.md and
docs/PLATFORM.md own this layer.
L2, the lifecycle. Sandbox and Session in microvms-core, and the microvm
commands over them: run, build --reuse --project, exec, cp, sync, named VMs,
attach, suspend, resume, terminate. Still generic: nothing here knows what a
coding agent is.
L3, the agent VMs. One call that gets you a VM with a coding agent installed, model
access wired, running as a non-root user, and a second call that hands the agent a task.
Everything L3 does is a composition of L2 calls that examples/coding-agents-on-bedrock/
already performed in a shell script; what L3 adds is that the composition lives in the
library, so the CLI, the bindings, and a harness call one function instead of
re-deriving the seven-step recipe and its four measured traps.
The rule this changes, and how the cost is bounded
Section titled “The rule this changes, and how the cost is bounded”Until this document the rule was: platform code exposes only generic primitives, and
agent-specific detail (CLI installs, model ids, credential wiring, config files) lives
only in examples/ and docs. docs/HARNESS-CAPABILITIES.md lists “harness provider
classes” as an explicit non-goal, and docs/STRATEGY.md says “not an orchestrator”.
The reason was churn: an agent CLI’s flags, config format, and model ids move on a
vendor’s cadence, and a platform library that hardcodes them decays.
L3 crosses that line on purpose, for two agents, with the churn contained:
- Every agent-specific fact is data in one table,
agents::profile, not code spread across the crate. Each entry carries the date and version it was verified against, the waydocs/PLATFORM.mddates a platform claim. Updating a profile is a one-file edit plus a live run. - Every default is overridable at the call site. The model id, the agent CLI’s install line, the headless command template, and the environment file are each parameters with a default; a caller whose vendor moved first passes the new value and does not wait for a release.
- The generic layer stays generic. L1 and L2 gained one read-only accessor for
L3 (
Sandbox::port) and nothing else.agentsdepends oncontrol,session, andsandbox; nothing depends onagents. - This is still not an orchestrator. L3 provisions one VM and runs one prompt in
it. Scheduling, retries across VMs, multi-agent coordination, and turn loops stay
outside this repository, per
docs/STRATEGY.md.
The non-goals paragraph in docs/HARNESS-CAPABILITIES.md still holds for harness
provider classes (a Harbor BaseEnvironment, an eve backend): those import the
harness’s packages and live in its ecosystem. L3 is the layer such a class would call.
Requirements
Section titled “Requirements”Written in the EARS shapes the rest of spec/ uses. AGENT-n is the id.
- AGENT-1. The
agentsmodule shall expose exactly two agent profiles,ClaudeCodeandCodex, as a closed enum, so a caller cannot name an agent the library has no recipe for. - AGENT-2. For any non-empty set of profiles,
agents::dockerfileshall produce a Dockerfile that starts with the client’s own agentd stanza lines (FROMthe managed base’sdocker_ref,COPY agentd,chmod), adds the union of the profiles’ install layers, creates uid and gid 1000 by appending to/etc/passwdand/etc/group, creates and hands/workspaceto that uid, setsWORKDIR /workspace, and ends with the stanza’sENV,EXPOSE,ENTRYPOINT [],CMD ["/agentd"]lines. The output shall pass every local guardSandbox::preflightruns. - AGENT-3. When a profile set is unchanged and the daemon binary is unchanged, the
derived image name shall be unchanged, so
build --reusesemantics hold: the name isagent-vm-<profiles>-<12 hex of the artifact content hash>. - AGENT-4.
agents::bedrock::mintshall produce a Bedrock bearer token from the caller’s AWS credential chain by SigV4 query presigning, with the lifetime the caller asks for, capped at the service’s 12-hour ceiling, and shall never write the token to a log, aDebugimpl, or a command line. - AGENT-5.
install_accessshall deliver model credentials to a running VM as files only (/workspace/.agent-envmode0600,/workspace/.codex/config.tomlwhen Codex is present), through the authenticated file route, then run one root exec that hands/workspaceto uid 1000. The environment file shall setPATHexplicitly. - AGENT-6.
install_accessshall write/workspace/.agent-vm.jsonnaming the installed profiles and their models, so a later process that holds only the VM’s identifiers can learn which agent to prompt without a local record. - AGENT-7.
promptshall run the agent’s headless command as uid 1000 and gid 1000, with/workspaceas the working directory, sourcing the environment file first, and shall refuse locally (zero wire calls) a task string that is empty. - AGENT-8.
agent-upagainst a name that is already registered shall not build or launch; it shall attach, mint a fresh token, re-runinstall_access, and reportreused: true. This is how a 12-hour token is refreshed on a long-lived VM. - AGENT-9.
agent-upshall launch with egress requested, since neither agent can reach Bedrock without it, and shall register the name only after the launch succeeds and the credentials are installed. - AGENT-10.
agent-promptwithout--agentshall read the marker from AGENT-6 and choose the sole installed profile; when two are installed it shall refuse and name both. - AGENT-11. Neither command shall introduce a new exit row; failures map onto the
existing table (
ERR_PRECONDITION,ERR_INVALID_ARG,ERR_EXEC_FAILED,ERR_NAME_TAKEN, and the wire rows).
The profile table
Section titled “The profile table”agents/profile.rs. Each row is a const with the fields below; the values are the ones
examples/coding-agents-on-bedrock/ measured working on 2026-09-02 in us-east-1.
| Field | ClaudeCode | Codex |
|---|---|---|
id |
claude-code |
codex |
install_lines |
dnf install nodejs22 nodejs22-npm python3 git tar gzip which findutils procps-ng; npm install -g @anthropic-ai/claude-code |
the same dnf line; npm install -g @openai/codex |
default_model |
global.anthropic.claude-opus-5 |
global.openai.gpt-5.6-sol |
env |
CLAUDE_CODE_USE_BEDROCK=1, ANTHROPIC_MODEL=<model>, AWS_BEARER_TOKEN_BEDROCK=<token> |
OPENAI_API_KEY=<token> |
env_extra |
CLAUDE_CODE_USE_BEDROCK=1, ANTHROPIC_MODEL=<model>, AWS_BEARER_TOKEN_BEDROCK=<token> |
AWS_BEARER_TOKEN_BEDROCK=<token> (what Codex reads on this host), OPENAI_API_KEY=<token> (the env_key the config declares) |
config_files |
none | /workspace/.codex/config.toml: provider bedrock, model_reasoning_effort = medium (Codex has no metadata for a Bedrock model id and otherwise sends none; a no-effort run declined a task once in five on 2026-09-10), base_url = https://bedrock-runtime.<region>.amazonaws.com/openai/v1, web_search = disabled (Codex advertises hosted web search by default and bedrock-runtime fails the turn), env_key = OPENAI_API_KEY, wire_api = responses, model = <model> |
headless_command(task) |
claude -p <task> --allowedTools Bash,Read,Edit,Write,Grep,Glob |
codex exec --skip-git-repo-check -s workspace-write <task> |
verified |
2026-09-10, us-east-1, @anthropic-ai/claude-code latest on that date |
2026-09-10, us-east-1, @openai/codex 0.154.0, bedrock-runtime host |
Shared, not per profile: HOME=/workspace, PATH=/usr/local/bin:/usr/bin:/bin,
AWS_REGION=<region>, uid and gid 1000, WORKDIR /workspace. The dnf line is
emitted once when both profiles are present.
The install lines pin nothing, so an image built today carries today’s CLI. That is a
choice, not an oversight: the --reuse hash covers the Dockerfile text, and a pinned
version would make every vendor release a Dockerfile edit and a rebuild. A caller who
wants a pin passes --claude-version or --codex-version, which appends @<version>
to the npm install and therefore changes the hash.
The Bedrock bearer token
Section titled “The Bedrock bearer token”agents/bedrock.rs ports the aws-bedrock-token-generator recipe to Rust on the
aws-sigv4 crate core already carries, so the CLI drops its uvx dependency for this
path and the token never touches a subprocess’s argv.
The recipe, verbatim from the reference implementation, is recorded in that file’s
module docs with the source permalink and the date it was read. In outline: presign a
POST to https://bedrock.amazonaws.com/ (the host is fixed and not regional; the
region enters only the credential scope) with Action=CallWithBearerToken in the query,
service bedrock, signed header host only, X-Amz-Expires at the requested lifetime (default and
ceiling 43200 seconds), then base64-encode the presigned URL with its scheme stripped and
&Version=1 appended, and prefix bedrock-api-key-. The unit test asserts the shape
(prefix, base64 alphabet, the decoded URL’s host, action, and expiry) against fixed
credentials and a fixed clock; the live conformance check asserts the token is accepted
by the real service, because a presign that is one canonical byte off still looks like
a token.
The token is a BearerToken newtype whose Debug prints its length only, following
.erpaval/solutions/best-practices/credential-structs-never-derive-debug.md.
The core API
Section titled “The core API”pub mod agents { pub enum Agent { ClaudeCode, Codex } // AGENT-1 pub struct AgentSpec { agent: Agent, model: Option<String>, cli_version: Option<String> } pub struct Profile { /* the table row */ } impl Agent { pub fn profile(self) -> &'static Profile; pub fn as_str(self) -> &'static str; }
pub const AGENT_UID: u32 = 1000; pub const AGENT_GID: u32 = 1000; pub const WORKDIR: &str = "/workspace"; pub fn dockerfile(specs: &[AgentSpec], base: &BaseImage, port: u16) -> String; // AGENT-2 pub fn image_stem(specs: &[AgentSpec]) -> String; // AGENT-3
pub mod bedrock { pub struct BearerToken(..); // Debug = length only pub struct Minted { token: BearerToken, expires_at: SystemTime } pub async fn mint(region: &Region, lifetime: Duration) -> Result<Minted, Error>; // AGENT-4 pub fn mint_with(credentials, region, lifetime, now) -> Result<Minted, Error>; // pure, tested }
pub struct BedrockAccess { region: Region, token: BearerToken } pub struct GuestFile { path: String, contents: Vec<u8>, mode: &'static str } pub fn provisioning_files(specs, access) -> Vec<GuestFile>; // AGENT-5, AGENT-6 pub async fn install_access(session: &Session, specs, access) -> Result<(), Error>; pub async fn installed_agents(session: &Session) -> Result<Vec<AgentSpec>, Error>; // AGENT-10 pub fn prompt_request(spec: &AgentSpec, task: &str, opts: &PromptOptions) -> Result<StartRequest, Error>; // AGENT-7 pub async fn prompt(session: &Session, spec, task, opts) -> Result<ExecHandle, Error>;
pub struct AgentVm { sandbox: Sandbox, specs: Vec<AgentSpec> } impl AgentVm { pub fn new(sandbox: Sandbox, specs: Vec<AgentSpec>) -> Result<Self, Error>; // refuses an empty set pub fn claude_code(sandbox: Sandbox) -> Self; // the names the request used pub fn codex(sandbox: Sandbox) -> Self; pub fn image_request(&self, binary: Vec<u8>, artifact_uri, build_role_arn, size) -> CreateImageRequest; pub fn launch_request(&self, image_identifier, execution_role_arn) -> RunRequest; // egress on pub async fn build(&mut self, request) -> Result<&Image, Error>; pub async fn launch(&mut self, request) -> Result<&Session, Error>; pub async fn install_access(&self, access: &BedrockAccess) -> Result<(), Error>; pub async fn prompt(&self, agent: Agent, task: &str, opts) -> Result<ExecHandle, Error>; pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport; pub fn sandbox(&self) -> &Sandbox; pub fn session(&self) -> Option<&Session>; }}The artifact upload stays the caller’s, exactly as it is for Sandbox::build_image:
S3 is not in core’s dependency set, and AgentVm::image_request returns the request
whose code_artifact_uri the caller fills before calling build. The free functions
exist beside the struct because the CLI’s refresh and prompt paths hold an attached
Session and no Sandbox; AgentVm’s methods delegate to them.
The CLI surface
Section titled “The CLI surface”Two flat commands, in the tree beside run and exec. Flat rather than a nested
agent group because the manifest generator, its cross-check test, and the docs site’s
Reference generator all read one level of subcommands, and a nested group would be a
second command grammar for two commands. port-forward set the hyphenated precedent.
microvm agent-up [BINARY] --vm-name NAME [--agent claude-code|codex]... [--claude-model ID] [--codex-model ID] [--claude-version V] [--codex-version V] [--project DIR] [--memory MIB] [--token-ttl-hours H] [--max-idle-sec S] [--suspended-sec S] [--auto-resume] [--max-duration-sec S] [--port P] [--state-dir DIR] <InfraFlags> <RegionFlags>
Builds the profile image under its content-hash name (reusing an existing one), launches
a kept, named VM with egress, mints a Bedrock token, installs it, optionally uploads
--project’s tree to /workspace (packed by the same sync module run <DIR> uses),
and registers the name. Against a registered name it takes the AGENT-8 refresh path.
--agent defaults to claude-code; both may be given. --memory defaults to 1024,
the example’s measured choice for peaky agent sessions. Envelope type
microvm.agent; keys vmName, microvmId, endpoint, agentToken,
imageIdentifier, imageName, imageReused, vmReused, agents
([{agent, model, cliVersion, headlessCommand}]), credentialExpiresAt (epoch seconds),
workdir, project ({workdir, uploadedBytes, uploadedMembers} or null), agentd.
microvm agent-prompt TASK [--agent A] [--timeout SEC] [--detach] [--exec-id ID] <AttachFlags> <RegionFlags>
Runs the headless agent over the task as uid 1000 in /workspace. Without --agent,
reads the guest marker (AGENT-10). --detach starts and returns the exec id for
exec --poll; the default waits up to --timeout (900 s, client-side; the daemon-side
exec carries no budget, as exec does) and acks. On the refresh path a --project tree is
uploaded before the credential install, so the same chown hands it to the agent. Envelope type
microvm.agent.prompt; keys execId, agent, model, phase, exitCode, stdout,
stderr, truncated. A non-zero agent exit earns ERR_EXEC_FAILED on a success
envelope, as exec does.
Teardown is microvm terminate NAME, which already exists. Streaming is
microvm exec --stream --name NAME --user 1000 --group 1000 '<headlessCommand>', and the
agents[].headlessCommand key in the agent-up envelope is there so a caller can do
that without knowing the template.
Verification
Section titled “Verification”mise run check covers: the Dockerfile derivation against every preflight guard, the
image-name stability property (same specs and binary give the same name; a version pin
changes it), the token shape under fixed credentials and clock, the provisioning file
set per profile combination, prompt_request field-by-field, the marker round trip
through the session recorder, the CLI guards (both commands fail closed through the
seam and name their door; a registered name skips the build door; an empty task is
refused with zero doors), and the manifest count.
The live half, per CLAUDE.md’s rule, is drive_agent_vm in conformance/run_rs.py:
agent-up with both profiles builds or reuses the image and launches; the marker names
both; the credential file exports the variable each installed agent reads (names only, so
no value reaches a log); agent-prompt --agent claude-code completes a Bash task with exit 0 and a
tool-call in its transcript; agent-prompt --agent codex creates a file that a following
exec can cat; a second agent-up against the same name reports vmReused: true
and a later credentialExpiresAt; terminate NAME releases the name. That section
needs Bedrock access to both default models in the conformance account and is the only
part of the suite that does; it reports the model ids it used.
The bindings
Section titled “The bindings”microvms-py and microvms-js carry the layer as AgentVm, AgentSpec, and
BearerToken, plus four module functions for a caller who holds only a session:
installed_agents, install_agent_access, prompt_agent, and mint_bedrock_token
(installedAgents, installAgentAccess, promptAgent, mintBedrockToken). The
sequence is the CLI’s, one method per step, and the upload stays the caller’s because
S3 is not in the core’s dependency set:
vm = microvms.AgentVm( microvms.Region.us_east_1(), [microvms.AgentSpec.claude_code(), microvms.AgentSpec.codex()],)image = vm.find_image(binary=agentd, build_role_arn=build_role)if image is None: name = vm.image_name(binary=agentd, build_role_arn=build_role) s3.put_object( Bucket=bucket, Key=f"{name}.zip", Body=vm.build_artifact(binary=agentd, build_role_arn=build_role), ) image = vm.build_image( binary=agentd, code_artifact_uri=f"s3://{bucket}/{name}.zip", build_role_arn=build_role, ).identifiervm.launch(image_identifier=image, execution_role_arn=exec_role)token = vm.install_access() # minted in process; token.expires_atresult = vm.prompt_sync("codex", "Create hello.py that prints hello, run it.")vm.terminate(delete_image=False)The binding holds the same lock the sandbox and every session it hands out hold, so a
terminate and a session call cannot interleave; vm.sandbox and vm.session reach
the same VM for suspend, resume, and file transfer. terminate(delete_image=True) deletes
only an image the object itself built, the sandbox’s existing rule; a reused image is
reported image_deleted: false with no failure, and the caller deletes it. The core’s AgentVm owns its
sandbox, which one binding class cannot share, so the bindings drive the layer through
the core’s free functions (image_request_for, launch_request_for, install_access,
prompt, spec_for) with the specs kept beside the lock. No refusal lives in a
binding: an unknown agent name, an empty or repeated spec set, a prompt for an agent
the VM does not carry, a blank task, and a token lifetime past the ceiling are all the
core’s messages. A BearerToken has no constructor and shows only its length; expose()
is the one door to the text, for a caller writing it into an environment themselves.
The Python stub is regenerated from the compiled module (mise run stubs) and the Node
index.d.ts from the napi surface, so both type surfaces follow the Rust.
Phase 2, deliberately out of this change
Section titled “Phase 2, deliberately out of this change”--streamonagent-prompt. Theexec --streampath exists and the envelope publishes the command to stream.microvm.tomlkeys for the agent flags.merge_configisrun-shaped and the agent flags would need their own precedence table.- Other model access paths (a vendor API key, an AgentCore gateway).
BedrockAccessis a struct rather than an enum until a second variant exists to name.