agentcore
github.com/kpenfound/agentcore runs headless coding-agent sessions and the
pieces a factory built on them needs: the session runner and its execution
boundary, an MCP host, a review pipeline, workspaces over git and jj,
operational primitives and a client for Jev, TypeSafe's System One model. busybees is
one consumer; its adapters supply the configuration, tracker mapping and
policy this module leaves to the caller.
go get github.com/kpenfound/agentcore@latest
Build with go build ./.... Tests never run on the host: run them inside
Dagger. CONTRIBUTING.md has the recipes.
Execution boundary
agent.Runner.Run takes an agent.Request with an execution-only Profile:
backend, models, effort, tools, prepared MCP entries, sandbox, shell and
environment. The caller selects profiles before running; Profile.Fallback
is the profile the session runs on instead when it has no capacity, agent
included, with its own fallback after it: ops.SelectProfile walks the
chain for a retry, and the claude backend passes a claude fallback's model as
--fallback-model so claude switches to it within the session.
The caller also supplies:
-
EnvironmentPrefix to strip stale inherited variables, even granted ones, and
the request's Env for session context and credentials. An empty prefix
strips nothing.
-
ValidOutcomes on each request and when calling agent.Report. A nil set
accepts any status; an empty, non-nil set accepts none. Validation errors list
the supplied statuses. Workflow-specific requirements belong to the caller.
-
Backend name prefixes, container labels, writable mounts, and optional
HostMCP launch details for a server that runs on the host during a container
session. A container session without HostMCP starts no host server.
-
SandboxDomains for Claude's network permissions. VCSAccess asks for the
workspace's VCS mounts and the request's VCSEnv / VCSContainerEnv identity,
credentials and configuration; the request's Grants.VCS must allow it.
ContainerEnv and explicit caller mounts remain caller-controlled.
-
Grants, the session's complete capabilities (below). A request without
them does not run.
-
An optional SkillPreparer and read-only skill cache mounts. Acquisition,
caching and configuration policy stay with the caller.
-
An optional CostCapUSD on the request (zero is no cap). The runner stops
the turn once the known cost its stream reports reaches the cap, and checks
the final cost when the turn ends; either way the result has CostCapped
set, ErrorSubtype cost_cap, the cost observed until then in CostUSD,
and a result file. The caller's cancellation returns an error and no
result. Each backend reports cost its own way:
| Backend |
Cost in the stream |
Stopped in flight |
| claude |
running total in each result event |
by claude itself, given --max-budget-usd |
| opencode |
each step_finish's part.cost, summed |
after the step that reaches the cap |
| pi |
each assistant message_end's cost, summed |
after the response that reaches the cap |
| codex |
none (tokens only) |
never: an unknown cost never reaches a cap |
agent.Runner.RunRestricted is the smaller entry point for a read-only
analysis turn. It accepts the same Request and uses the same backend command
builders, stream parsers, timeout and process-group lifecycle as Run, but it
owns the capability contract instead of accepting caller grants. The request
may carry only a workspace, prompt, resume id, environment, cost cap and the
profile's agent/model/effort/turn/timeout/fallback settings. It receives no MCP server,
outcome contract, writable mount, VCS access, skill, plugin or hook. Variables
with the runner's EnvironmentPrefix and VCS identity variables are removed. Claude is held to named read-only tools,
empty settings sources and strict empty MCP configuration; Codex is held to its
read-only sandbox, disabled command/network/plugin features and a fail-closed
inventory that disables every inherited MCP server. Restricted Codex also sets
agents.enabled=false: disabling multi-agent feature flags alone does not
remove collaboration tools for models that advertise them. Its JavaScript
functions.exec host remains available for configured read-only MCP tools.
OpenCode runs in pure mode
as a private agent whose effective configuration is probed before launch: only
read, grep and glob are allowed and every inherited MCP server must resolve
disabled. The turn is refused before that when OpenCode would load a custom
tool (see the OpenCode tools bullet below). Pi loads no extension (including the ordinary MCP adapter), skill,
prompt template or context file and receives only its read, grep, find and ls
tools. Every backend descriptor declares whether it can establish this contract
and whether it supports follow-up; Codex resumes its thread through
thread/resume. A capacity failure walks Profile.Fallback, applying the same contract to every
attempt and clearing a resume id before crossing to another backend. The
returned RestrictedResult.Agent, RestrictedResult.Model and
RestrictedResult.ClaudeID belong to the backend that answered.
MCP entries contain public context in Env and credential names in EnvVars.
Pass credential values in the request environment. MCPEntries can expand
configured environment references before entries are passed to the runner;
prepared entries are not expanded again. BearerTokenEnv names the process
variable holding a remote server's bearer token; each backend writes its native
environment reference. HostMCP uses this field for its generated token.
Codex receives the session directory through
-c shell_environment_policy.set.<prefix>SESSION_DIR=... even without MCP.
Orphan scans use procs.CodexMarker(prefix) with the same environment prefix;
LegacyCodex can also match a caller's older MCP-based marker. With the default
empty prefix, procs.Find needs no marker overrides.
Discovery deletes the stale pid, container id and server pid files it reads,
the cleanup a caller stopping sessions wants. A caller that only inspects
asks through procs.Finder{ReadOnly: true}, which reports the same sessions
and leaves every file in place.
An ordinary pi session (AgentPi) has no MCP support of its own: the runner loads
PiMCPAdapter (npm:pi-mcp-adapter) and then Profile.PiPackages with
-e, writes the MCP entries to pi-mcp.json in the session directory, and
passes it as the adapter's --mcp-config with PI_MCP_CONFIG_MODE=exclusive.
Restricted execution loads neither the adapter nor configured packages.
Adding a backend
A new agent CLI joins in one descriptor and one implementation, and every
other list derives from or is held to them. The checklist:
- Metadata. Add the
Backend to agent.Backends (backends.go): Name
(also the value of the agent setting and the default executable's
basename), Credentials (the variables the CLI reads its credential
from), ProviderEnv (its configuration and credential variables, *
for a prefix), ArgvMarker (whether the command line carries a marker
the process-table scan finds), a bin selector for the runner's
executable override, and the implementation. AgentCredentials and the
provider environment a caller grants derive from the descriptors.
- Ordinary execution. Give the implementation its
command (the
executable comes from b.executable(r), never named directly) and its
consume (the stream reduced to a streamEnd, every cost it carries
reported to the costMeter as it is read), and cover both with a
fixture test in fixture_test.go's style, backendNamed for the
descriptor lookup.
- Restricted review execution. Extend
command's paths.restricted
branch to establish the whole read-only floor — no write, run or
fetch tool, no MCP (inherited servers inventoried and disabled, a
probe that cannot answer failing the launch), no writable VCS access,
inherited configuration unable to widen any of it — and cover it in
restricted_test.go: the command line, inherited-configuration
attacks, the fail-closed probe, and the result fields. A restriction
that cannot be established is refused before any process starts, never
silently dropped.
- Capability declarations. Set
Restricted (RestrictedCapabilities):
Supported only when the command builder can establish the complete
floor, and FollowUp when ResumeID is meaningful. The pointer is
nil when a descriptor forgets, which TestBackendsAreDeclaredCompletely
reports; TestBackendRestrictedCapabilities pins each declaration. Set
WritableTools for every entry of agent.Placements (see
Grants): Supported only where command holds a writable
turn to the granted built-in tools, and a Remedy everywhere else.
TestEveryBackendDeclaresWritableToolsForEveryPlacement pins them.
- Completeness.
procs.AgentExecutables names the executable
(TestBackendsMatchProcsExecutables holds the two together); a
caller's doctor carries the toolchain check; SbxTemplates has a template or the sandbox is
refused for it; profile.go's per-agent facts (PiPackages,
SbxTemplates) say what applies; and the repository's review and role
documentation names the agent where it lists the ones there are.
Grants
agent.Grants lists everything a session may have:
req.Grants = &agent.Grants{
Env: []string{"PATH", "HOME", "ANTHROPIC_API_KEY", "LC_*"},
Tools: []string{"Read", "Edit", "mcp__tools"},
Mounts: []agent.Mount{{Path: "/", Access: agent.ReadOnly}, {Path: work, Access: agent.ReadWrite}},
VCS: false,
}
Env is the complete allowlist of variable names; NAME* is a prefix. A
host variable is inherited only when listed, and a variable the profile or
request sets must be listed. Without VCS, an entry that could match a gh,
git or SSH-agent variable (GH_*, GITHUB_*, GIT_*, GCM_*,
SSH_AUTH_SOCK, ...) is refused.
Tools are built-in tool names, or agent.ToolsAll, and mcp__<server>
for each MCP server. The profile's AllowedTools, MCP and HostMCP may
name less, never more; DisallowedTools only narrows. A grant without
ToolsAll is held to the tools it names where the backend's
WritableTools declares the placement supported, and refused with
ErrUnsupported everywhere else (see below).
Mounts are absolute, clean, existing paths, ReadOnly or ReadWrite,
judged after their symbolic links are resolved; Within confines them all.
The working directory must lie inside one. Without VCS, a writable mount
inside or holding .git, .hg, .jj or .svn is refused.
VCS grants version control. Without it, a host session finds gh, git,
hg, jj and svn shadowed on PATH by stand-ins that exit 126, and
Claude's sandbox settings deny them.
DaggerEngine grants a Dagger engine, unix://<socket> or
tcp://<host>:<port>, to a SandboxSbx session whose Profile.Dagger
asks for that engine. Every boundary refuses it otherwise: granted to a
profile that does not ask (ErrUnsupported), asked for without the grant
or with another engine granted (ErrNotGranted), or in any other mode
(ErrUnsupported).
HostServers grants a SandboxSbx session MCP servers the caller already
runs on the host's loopback, each by name and port. The server must also
be granted as mcp__<name>. See the sandbox below; every other mode
refuses the grant with ErrUnsupported.
Each backend declares, for every agent.Placement (the sandbox, and for a
host sandbox whether Profile.Confine is set), whether an ordinary turn,
one that may write, can be held to the built-in tools its grants name. A
placement declared unsupported is refused before anything starts, with an
ErrUnsupported error that names the agent, the placement and the remedy:
builder: grant cannot be enforced: agent "opencode" cannot hold a writable turn to its granted built-in tools in sandbox "claude"; sandbox "claude" runs claude alone; run opencode in sandbox "none", "container" or "sbx"
| Agent |
none |
none confined |
claude |
claude confined |
container |
sbx |
claude |
yes |
yes |
yes |
yes |
yes |
yes |
opencode |
yes |
yes |
no |
no |
yes |
yes |
codex |
yes |
yes |
no |
no |
yes |
yes |
pi |
no |
no |
no |
no |
no |
no |
-
Claude is started with --tools.
-
Opencode tools carry opencode's own permission names: bash,
codesearch, edit (every tool that changes a file), external_directory
(the file tools outside the working directory), glob, grep, list,
lsp, read, skill, todoread, todowrite, webfetch and websearch.
Any other name is refused, and so is task: its subagents run under their
own permissions, not the turn's. The turn runs with --pure as a
generated primary agent, agentcore-granted, whose permissions deny *, allow
each granted tool, and allow <server>_* for each of the session's MCP
servers. A server whose <server>_* would also match one of opencode's
own permissions is refused: a server named external, whose
external_* matches external_directory. Before the model starts, opencode debug config runs where the
turn will run: on the host, under the same confinement, in a container of
the same image with the same binds, or in the same Docker Sandbox. Every
inherited MCP server is disabled through OPENCODE_CONFIG_CONTENT, and a
second inventory refuses the turn when the agent is missing, disabled, not
primary, has any permission other than those or a tools map, when an
inherited server is still enabled, or when one of the session's servers
is missing, disabled or differs from what the runner wrote in any key
(command, url, environment, headers, or a key added). A {env:NAME}
reference matches as written or resolved against the turn's
environment.
Custom tools refuse the turn before any of that. OpenCode imports every
*.js and *.ts file in the tool/ and tools/ directories of its
configuration roots as a tool, even with --pure, and it does so before
any permission applies. A file named after a granted tool, such as
read.ts, takes that tool's permission. OpenCode's roots are
$XDG_CONFIG_HOME/opencode (else ~/.config/opencode), every
.opencode directory from the working directory up to the worktree,
~/.opencode and $OPENCODE_CONFIG_DIR. Where the turn will run, and
with its environment, the runner starts the opencode executable as its
Bun runtime (`BUN_BE_BUN=1 opencode --config=/dev/null --no-env-file -e