Files
Yao e8c021bc10 feat(sdk): expose the error envelope's type and code on API errors (#470)
`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
2026-09-25 17:17:07 +08:00

19 KiB

Usage Guide

managed-agents provides a local control plane for building, running, and debugging managed agents. The usual workflow is:

  1. Create or import an agent.
  2. Attach skills, tools, MCP servers, files, memory stores, or credentials.
  3. Start a session in an environment.
  4. Inspect the transcript and debug event stream.
  5. 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:

  1. Change the relevant field.
  2. Click Save settings; the Dashboard validates the change before saving.
  3. Optionally run Check configuration for a local capability check when you need diagnostics before or after saving.
  4. 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 local exposes 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 docker and kubernetes. Setting resources on local or remote logs 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, and tar (tar is what kubectl cp uses). The default node:22-slim has all three.
  • namespace must be a lowercase RFC 1123 label and defaults to default.
  • Leaving service_account empty creates the Pod with automountServiceAccountToken: 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-agents and managed-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_KEY to provide a stable credential encryption key across runtime moves.
  • Create a managed API key in the Dashboard or set MANAGED_AGENTS_API_KEY before exposing the runtime beyond a trusted local network.