`ManagedAgentsApiError` exposed only `status` and a message built from
`error.message`, so the runtime's error taxonomy was unreachable from the SDK:
API error 400: agent must be a standard agent id
The published envelope is structural, and the runtime builds it in one place
(`src/api/routes/sessions.ts:766`):
{"error":{"type":"invalid_request_error","code":"invalid_agent_ref","message":"..."}}
A caller is meant to branch on that identity. Without it, distinguishing a wrong
agent reference from a malformed body means substring-matching English prose that
is not a stable interface, and any rewording of a message is a silent breaking
change for the consumer that was forced to match on it. This closes the client
half of the error taxonomy (#464) and does not change the canonical type (D11).
* `error.type` and `error.code` carry the runtime's own values, and are
`undefined` when the response carried no envelope or named no specific cause
(`not_found` is a type, not a code under `invalid_request_error`).
* The message is deliberately **unchanged** — `API error 400: agent must be a
standard agent id` — so this is additive for anyone already matching on it.
Measured: only one assertion in the suite matches an error message
(`tests/integration/sdk.test.ts:251`, `/API error 404/`).
* The error body is now read exactly once. The previous implementation called
`res.json()` and then `res.text()` on the same `Response`, whose body is
single-use, so the non-JSON fallback could never succeed: a non-JSON failure
was reported as `statusText`, not as the body the runtime sent. The fallback
was dead code that looked alive.
* `requestText` — which backs artifact and file content plus metrics — goes
through the same reader. It used the raw body as the message, so a JSON
envelope was reported as raw JSON text with no fields readable.
* A JSON body that is not an envelope keeps the previous `statusText` fallback,
so only the envelope's fields are newly reachable; an unrelated JSON payload
cannot leak into a log line.
Verification: `npm run release:check` — see PR body for counts. The integration
test drives a real listener over real SQLite, so the codes are produced by the
routes: `invalid_agent_ref` and `agent_required` from `POST /v1/sessions`,
`not_found` from `GET /v1/sessions/{id}` and from the `requestText` path, and the
message pinned byte-for-byte so adding fields cannot alter the sentence existing
callers log. The non-JSON and malformed-envelope branches are covered by a
stubbed fetch, because no route in the suite answers a failure with a non-JSON
body.
Not covered: no Docker daemon, no Kubernetes cluster, and no model provider
credentials exist on this host, so no provider boundary is exercised;
`invalid_initial_events` was not reachable, because that route resolves a default
environment before it validates `initial_events` and answers
`Environment not found: env_default` with no code.
Reverse probes, each differing from the fix in one respect:
A. type/code dropped from the constructor -> see PR body
B. body read twice, as before -> see PR body
C. envelope required to be read from the
raw text rather than parsed -> see PR body
19 KiB
Usage Guide
managed-agents provides a local control plane for building, running, and
debugging managed agents. The usual workflow is:
- Create or import an agent.
- Attach skills, tools, MCP servers, files, memory stores, or credentials.
- Start a session in an environment.
- Inspect the transcript and debug event stream.
- Iterate on the agent definition and save new versions.
Workspace Layout
A workspace is a folder that contains runtime configuration, runtime state, and optional seed agent definitions and skill packages. Live metadata is stored in SQLite under the workspace state directory.
my-agents/
+-- agents/ # Optional seed agent definitions
| +-- assistant.yaml
+-- skills/ # Optional seed skill packages
| +-- code-review/
| +-- SKILL.md
+-- .managed-agents/
+-- config.yaml
+-- data.db # SQLite metadata store
+-- logs/
| +-- runtime.log
+-- files/ # Uploaded file bytes
+-- skills/ # Uploaded custom skill package assets
+-- snapshots/ # Session workspace snapshots
+-- sandbox/ # Local session workspaces
The workspace is portable. Commit examples, templates, config, and any seed
definitions you intentionally maintain. Keep .managed-agents/data.db,
.managed-agents/logs/, .managed-agents/files/, and sandbox state out of
source control unless you intentionally want to snapshot local runtime data.
Workspaces are listed in a registry at $MANAGED_AGENTS_HOME/workspaces.json,
which defaults to ~/.managed-agents/workspaces.json. Set MANAGED_AGENTS_HOME
to keep several registries apart — for example one per project, or a throwaway
one in tests — and managed-agents workspace list will read that one instead.
Agent Definitions
Agents can be imported from YAML files in agents/ or created through the
Console/API. Once loaded, the runtime source of truth is SQLite.
name: assistant
description: Helps with development tasks.
model: gpt-4o
system: |
You are a helpful assistant. Answer clearly and use tools when needed.
mcp_servers: []
tools:
- type: agent_toolset_20260401
default_config:
enabled: true
permission_policy:
type: always_allow
skills:
- type: custom
skill_id: skill_code-review
metadata:
owner: platform
Agent ids are stable object identifiers. YAML seed agents use deterministic ids
when they are first imported, while agents created through the API or Console
receive server-generated agent_... ids. Use the returned id in API calls,
sessions, and SDK requests; treat name as a human-readable display field.
Dashboard Workflow
Start the runtime:
managed-agents start
Open:
http://127.0.0.1:3000/dashboard
The Dashboard includes:
- Workspace and local runtime status
- Agent templates and agent versions
- Session creation and session debug timelines
- Runtime Settings for the single workspace model vendor, loop engine, storage backends, context-memory backend, and default sandbox
- Environments
- Credential vaults and credentials
- Memory stores and memory entries
- File upload and file resources
- Skill upload and skill details
Runtime Settings
Open Settings > Models, Loop engine, Storage, Memory, or Sandbox to
edit the workspace runtime configuration. Settings V2 stores one versioned JSON
document in SQLite under the runtime data directory. Each Console page edits its
own section of that document: Models edits model, Loop engine edits
loop_engine, Storage edits storage, Memory edits memory, and
Sandbox edits sandbox. The Form and JSON tabs are two views of that current
section, and saving merges the section back into the versioned document.
The usual sequence is:
- Change the relevant field.
- Click
Save settings; the Dashboard validates the change before saving. - Optionally run
Check configurationfor a local capability check when you need diagnostics before or after saving. - Restart the runtime when the page shows
Restart required.
All first-release Settings V2 fields require a runtime restart before they
become effective. Until restart, API responses expose both saved_config and
effective_config; sessions continue using the effective revision.
The same document is available to code. client.settings.get() returns the
GET /v1/x/settings response — revision, saved_config, effective_config,
restart_required, secret_states, and the adapters catalogue.
client.settings.patch({ model: { vendor: 'anthropic' } }) merges that partial
document over the stored one and writes the result with the revision it read, so
a concurrent writer is refused with 409 instead of being overwritten; there is
no automatic retry. A document the runtime would reject is refused before the
write and throws RuntimeSettingsValidationError, whose errors name each
failing field, including the environment variable behind an unresolvable
${NAME} reference. client.settings.validate(config) asks about a candidate
document without saving it and returns { valid, errors, warnings }. Secrets
survive the round trip: a stored literal key reads back as ********, and that
sentinel means "keep the stored value" when it is written again.
The workspace has one active model vendor, one built-in loop engine, SQLite
metadata storage, local artifact storage, one context-memory backend, and one
default sandbox provider. Named Environments can still override the default
sandbox per session. Planned adapters such as S3, mem0, MemU, Harness, Codex,
and Claude remain unavailable until their runtime implementations exist; Docker,
Kubernetes, and remote sandbox providers appear as available only when the
current runtime can reach their transport. Remote sandbox maps to the
self-hosted worker queue: configure the worker API URL and key so external
workers can claim and complete queued work items; the Settings check calls the
remote /v1/x/health endpoint.
Sandbox Backends
A sandbox is where an agent's tool commands actually run. One sandbox is bound to one session for that session's lifetime.
Each backend declares what it can do, and the runtime reads those capabilities instead of assuming them. Requesting something a backend cannot provide is reported in the runtime log rather than silently dropped.
| Backend | Selected as | Isolated from runtime host | Host workspace | Resource limits | Transport |
|---|---|---|---|---|---|
| Local process | local |
No | Yes | No | Child process |
| Docker | docker |
Yes | No | Yes | docker CLI |
| Kubernetes | kubernetes |
Yes | No | Yes | kubectl CLI |
| Self-hosted worker | remote in Settings, self_hosted in an Environment |
Runs off-host | No | No | Work-item queue |
Consequences worth knowing before choosing one:
- Local is not a security boundary. File tools are confined to the session workspace and the child process environment is reduced to an allowlist, but a shell command still runs as the same OS user on the same machine as the runtime: it can read outside the workspace and reach the network. Use it for trusted local development, not for running untrusted agent output.
- Workspace snapshots need a host workspace. Only
localexposes one, so snapshots are unavailable on the other three. Enabling them anyway logs a warning naming the missing capability. - Resource limits are honored only by
dockerandkubernetes. Settingresourcesonlocalorremotelogs a warning instead of appearing to apply.
A backend is offered only when the runtime can reach its transport: no Docker
daemon means docker is not registered, and no reachable cluster means
kubernetes is not registered. An Environment naming a backend that is not
registered fails when the session provisions its sandbox, with an error listing
the backends that are registered. It does not fall back to local execution —
quietly running unsandboxed after an isolated backend was requested would be a
worse outcome than a failed session.
Startup logs the registered backends:
Sandbox: local, docker, self_hosted
Kubernetes Sandboxes
Requires kubectl on PATH and a reachable cluster. Each session becomes one
Pod running sleep infinity; commands run through kubectl exec and files move
through kubectl cp.
Configure it under Settings > Sandbox, or per Environment:
{
"sandbox_provider": "kubernetes",
"image": "node:22-slim",
"kubernetes": {
"namespace": "agent-sandboxes",
"context": "staging",
"service_account": ""
}
}
- The image needs
/bin/sh,find, andtar(taris whatkubectl cpuses). The defaultnode:22-slimhas all three. namespacemust be a lowercase RFC 1123 label and defaults todefault.- Leaving
service_accountempty creates the Pod withautomountServiceAccountToken: false, so sandboxed commands cannot call the Kubernetes API. Only set it when an agent genuinely needs cluster access, and scope that account's RBAC accordingly. - Pods are labeled
app.kubernetes.io/managed-by=managed-agentsandmanaged-agents/session-id=<session id>, and are deleted when the session reaches a terminal state.
See Deployment Examples for the RBAC the runtime itself needs to create these Pods.
YAML model entries and legacy provider rows are bootstrap/import data for a new workspace. After Settings V2 is seeded, normal Dashboard edits do not rewrite source-controlled YAML files and legacy provider mutation endpoints are read-only compatibility failures.
Create An Agent
Use the Dashboard Create agent action, or add a seed YAML file in agents/ and
reload to import it into SQLite:
managed-agents reload
Create an agent through the API:
curl -X POST http://127.0.0.1:3000/v1/agents \
-H "Content-Type: application/json" \
-d '{
"name": "assistant",
"description": "Helps with development tasks.",
"model": "default",
"system": "You are a helpful assistant.",
"tools": [{ "type": "agent_toolset_20260401" }],
"skills": [],
"metadata": {}
}'
Start A Session
A session is a run of an agent inside an environment.
curl -X POST http://127.0.0.1:3000/v1/sessions \
-H "Content-Type: application/json" \
-d '{
"agent": "agent_assistant",
"environment_id": "env_default",
"title": "Local smoke test"
}'
The response contains a sesn_... id.
Send a user message:
curl -N -X POST http://127.0.0.1:3000/v1/sessions/SESSION_ID/messages \
-H "Content-Type: application/json" \
-d '{"content": "Hello", "stream": true}'
List events:
curl http://127.0.0.1:3000/v1/sessions/SESSION_ID/events
Resume a live event stream:
curl -N http://127.0.0.1:3000/v1/sessions/SESSION_ID/events/stream \
-H "Last-Event-ID: 42"
Stop a session:
curl -X POST http://127.0.0.1:3000/v1/sessions/SESSION_ID/stop
Attach Files To A Session
Upload a file:
curl -X POST http://127.0.0.1:3000/v1/files \
-F "file=@notes.txt"
Create a session with the file mounted under /uploads/:
curl -X POST http://127.0.0.1:3000/v1/sessions \
-H "Content-Type: application/json" \
-d '{
"agent": "agent_assistant",
"environment_id": "env_default",
"resources": [
{
"type": "file",
"file_id": "file_abc123",
"mount_path": "/uploads/notes.txt"
}
]
}'
Attach A Memory Store
Create a memory store:
curl -X POST http://127.0.0.1:3000/v1/memory_stores \
-H "Content-Type: application/json" \
-d '{"name": "project-memory", "description": "Long-term project notes"}'
Add a memory:
curl -X POST http://127.0.0.1:3000/v1/memory_stores/MEMORY_STORE_ID/memories \
-H "Content-Type: application/json" \
-d '{"path": "/notes/overview", "content": "Use concise release notes."}'
Mount the store into a session:
{
"type": "memory_store",
"memory_store_id": "memstore_abc123",
"access": "read_write",
"instructions": "Use this store for durable project notes."
}
Use Credential Vaults
Credential vaults hold credentials that sessions can use without writing secrets into agent YAML files.
Create a vault:
curl -X POST http://127.0.0.1:3000/v1/credential-vaults \
-H "Content-Type: application/json" \
-d '{"name": "production-tools"}'
Add an environment variable credential:
curl -X POST http://127.0.0.1:3000/v1/credential-vaults/VAULT_ID/credentials \
-H "Content-Type: application/json" \
-d '{
"name": "github-token",
"auth_type": "environment_variable",
"variable_name": "GITHUB_TOKEN",
"value": "ghp_example",
"network": {
"type": "limited",
"allowed_hosts": ["api.github.com"]
},
"injection_locations": ["request_headers"]
}'
Attach one or more vaults when creating a session:
{
"vault_ids": ["vlt_abc123"]
}
TypeScript SDK
import { ManagedAgentsClient } from 'managed-agents/sdk';
const client = new ManagedAgentsClient({
baseUrl: 'http://127.0.0.1:3000',
});
const session = await client.sessions.create({
agent: 'agent_assistant',
environment_id: 'env_default',
});
for await (const event of client.sessions.chat(session.id, 'Hello')) {
if (event.type === 'agent.message_chunk') {
process.stdout.write(event.delta ?? '');
}
}
A refused request throws ManagedAgentsApiError, which carries the published
error envelope's identity as well as its prose: status, type (for example
invalid_request_error, not_found, or conflict), and code when the
runtime names a specific cause (invalid_agent_ref, budget_reached,
unsupported_model_field, and others). Branch on those instead of on the
message, which is prose and may be reworded:
import { ManagedAgentsApiError } from 'managed-agents/sdk';
try {
await client.sessions.create({ agent: 'assistant' });
} catch (error) {
if (error instanceof ManagedAgentsApiError && error.code === 'invalid_agent_ref') {
// `agent` takes an agent id, not a name.
}
throw error;
}
type and code are undefined when the response carried no envelope or the
runtime named no specific cause. The message is unchanged, so existing code that
matches on it keeps working.
CLI Commands
managed-agents init
managed-agents start --host 127.0.0.1 --port 3000
managed-agents list
managed-agents reload
managed-agents chat agent_assistant --message "hello"
managed-agents session create --agent agent_assistant
managed-agents session message <session-id> --message "hello"
managed-agents session tail <session-id>
managed-agents session inspect <session-id>
managed-agents session logs <session-id>
managed-agents settings get
managed-agents settings validate
managed-agents settings set-model --vendor anthropic --api-key-env ANTHROPIC_API_KEY
managed-agents environments list
managed-agents environments create --name staging --hosting-type local
managed-agents environments inspect <environment-id>
managed-agents environments update <environment-id> --sandbox-provider docker
managed-agents environments archive <environment-id>
managed-agents environments worker-keys <environment-id>
managed-agents workspace create ./my-agents --name "My agents"
managed-agents workspace open ./existing-project
managed-agents workspace list
managed-agents workspace resolve <workspace-id-or-name-or-root>
managed-agents workspace remove <workspace-id-or-name-or-root>
managed-agents template list
managed-agents template install <template-name-or-path>
managed-agents template create <name>
session create prints the new session id, and --agent takes an agent id
(default: the first loaded agent): a session's agent field is an id, not a name,
and the API refuses a name with 400 invalid_agent_ref. session message streams
the reply unless --no-stream is passed, which returns as soon as the runtime
accepts the message. session tail follows the live event stream and does not
exit on its own; session inspect prints a summary (or the session and its events
as JSON with --json) and session logs prints every recorded event, one JSON
object per line. Every command accepts --port and --api-key.
environments create requires --name; --hosting-type is one of cloud,
local, or self_hosted, and --config-json supplies the backend config as a
JSON object. A --config-json value that is not valid JSON, or that parses to
something other than an object, is refused locally with the option named. update
sends only the fields you pass, so an update that renames an environment keeps its
description and config. archive is terminal: the environment disappears from
list, a later inspect answers 404, and archiving it again is refused. Every
command accepts --port, --api-key, and --json.
The settings commands read and write the same document as the Console. settings get prints
the saved values next to the effective ones, because a saved change is not in use until the
runtime restarts. settings set-model writes model.vendor and, with --api-key-env, a
${NAME} reference rather than a key: the runtime resolves that variable in its own
environment, so a name that is not set there is refused with the variable named, and no key
reaches the config file or this process's arguments. It sends only the fields you pass, so
changing the vendor keeps the stored credential and every other section. settings validate
checks the stored document, prints each issue, and exits non-zero while it is invalid. All
three accept --port, --api-key, and --json.
The workspace commands are the exception: they do not talk to a running runtime,
so they take no --port or --api-key. workspace create builds the folder
layout, a starter config.yaml, and a registry entry; workspace open only
registers a folder that already exists and writes nothing into it. Both accept
--name and --data-dir, and re-registering the same folder updates its single
entry rather than adding a second. workspace list prints the most recently
opened first, workspace resolve accepts an id, a name, or a root and marks the
entry as just opened, and workspace remove deletes the registry entry only
— the folder and its runtime data are left in place. resolve and remove exit
non-zero when nothing matches. list, create, open, and resolve accept
--json.
Operational Notes
- Keep credentials in vaults or environment variables, not in agent YAML files.
- Keep uploaded file resources below 10 MB per file.
- Keep skill uploads below 8 MB per package.
- Use
MANAGED_AGENTS_SECRET_KEYto provide a stable credential encryption key across runtime moves. - Create a managed API key in the Dashboard or set
MANAGED_AGENTS_API_KEYbefore exposing the runtime beyond a trusted local network.