Every deployed agent is callable through the platform's /v1 surface — the
same invoke chain the Chat playground uses. Interactive docs: /api/docs.
Auth: X-Api-Key header. Create a key in the console (Chat → API KEYS) or:
curl -s -X POST localhost:8000/api/apikeys -H 'Content-Type: application/json' \
-d '{"name": "integration"}'
# → {"id": "…", "prefix": "lp_live_ab12…", "key": "lp_live_<full-key-shown-once>"}Keys are stored hashed (sha256) — the full key is shown exactly once. 密钥仅创建时展示一次,后端只保存哈希。
Key lifecycle. A key records the console account that minted it
(created_by = its users.id, plus created_by_name; both null on keys minted
before this existed, which the console shows as "unknown") and last_used_at
(stamped on a successful /v1 call, at most once a minute). A key lives only as
long as its creator may hold it: once that account is deleted, disabled or
expired, or — for a member — loses its grant to the key's workspace, the key
answers 401 auth.invalid_api_key (the same code as any unusable key; /v1 never
says why) and is flipped to disabled. Admin disable / delete and grant removal
disable the creator's keys at once; expiry has no write event, so it lands on the
key's next use. Keys are disabled, never deleted — they stay listed in
GET /api/apikeys (created_by, created_by_name, last_used_at,
creator_offboarded: deleted | disabled | expired | no_workspace_grant |
null). In a shared workspace any member can disable / enable any key; under
isolated visibility a member lists and toggles only the keys they minted (others →
404 apikey.not_found; admins manage all). POST /api/apikeys/{id}/enable on an
offboarded creator's key is refused with
409 apikey.creator_offboarded (detail.reason). Keys minted by the config-driven
admin or the auth-disabled local operator (no users row) and legacy keys are never
offboarded. A key follows its creator's current workspace visibility: under
isolated, a member-minted key lists and invokes only the agents that member can see
(another agent → 404 agent.not_found), re-evaluated on every call; keys minted by an
admin, the config admin or before creators were recorded are unrestricted. Expiry,
scopes and agent allowlists are not implemented.
curl -s -X POST localhost:8000/v1/agents/<AGENT_ID>/invoke \
-H "X-Api-Key: $LP_KEY" -H 'Content-Type: application/json' \
-d '{"prompt": "What is 2+2?", "session_id": null}'
# → {"agent":"…","text":"4","session_id":"…","latency_ms":1234}curl -N -s -X POST localhost:8000/v1/agents/<AGENT_ID>/invoke-stream \
-H "X-Api-Key: $LP_KEY" -H 'Content-Type: application/json' \
-d '{"prompt": "Tell me a two-sentence story."}'
# event: meta → {"session_id": "…", "mode": "stream"}
# event: delta → {"text": "Once"} … (incremental chunks)
# event: done → {"latency_ms": 2100}Pass the returned session_id on the next call to continue the conversation
(session context + AgentCore Memory ride on it).
The stream may also carry tool ({name, id}, a tool call started) and, for a
Managed Harness, policy_denied — a Gateway tool call a Cedar policy refused:
{tool, tool_use_id, reason, policy_id, gateway_id} (tool is the catalog name
<target>___<tool> when the call went through the signed-in user's Gateway alias,
then gateway_id names the workspace gateway; otherwise the Harness tool name and
null; policy_id is set only when the reason names the determining policy).
The agent's own answer still follows; clients may ignore both events.
Ordinary Agent/proposal timeout_seconds defaults to 600 seconds and max_iterations to 100; explicit
values are retained. Harness executes the corresponding native timeoutSeconds
budget. A timeout returns 504 harness.execution_timeout; cancellation returns
502 harness.execution_cancelled; exhausted execution limits return
502 harness.execution_limit; an otherwise unfinished response returns
502 harness.incomplete_response. Each carries detail.stop_reason. An already-open
SSE stream reports the error event instead of completing successfully. These are
execution outcomes, separate from the SDK/network read timeout.
import requests
BASE, KEY, AGENT = "http://localhost:8000", "lp_live_…", "<AGENT_ID>"
# sync
r = requests.post(
f"{BASE}/v1/agents/{AGENT}/invoke",
headers={"X-Api-Key": KEY},
json={"prompt": "How many vacation days does EMP-1024 have left?"},
timeout=120,
)
print(r.json()["text"])
# streaming (SSE)
with requests.post(
f"{BASE}/v1/agents/{AGENT}/invoke-stream",
headers={"X-Api-Key": KEY},
json={"prompt": "Summarize our HR policy in one line."},
stream=True, timeout=300,
) as stream:
for line in stream.iter_lines(decode_unicode=True):
if line.startswith("data:"):
print(line[5:].strip())Errors use the platform envelope {code, message, detail} — e.g.
auth.missing_api_key (401), auth.invalid_api_key (401 — unknown, disabled, or
offboarded-creator key), agent.not_active (409), agent.not_found (404).
An AWS-side failure the platform did not map to a service code of its own
(kb.not_found, memory.unavailable, …) is still returned as an envelope, never
as a bare 500 Internal Server Error or as botocore's
An error occurred (…) when calling the … operation: text. The global
ClientError handler in app/core/errors.py maps the AWS error code:
| AWS error code | HTTP | code |
|---|---|---|
ResourceNotFoundException |
404 | aws.not_found |
ValidationException |
400 | aws.validation |
AccessDeniedException, UnauthorizedException |
403 | aws.access_denied |
ThrottlingException, TooManyRequestsException, ServiceQuotaExceededException |
429 | aws.throttled |
ConflictException, ResourceInUseException, RetryableConflictException |
409 | aws.conflict |
message is the AWS message with the botocore prefix stripped; detail is
{"aws_error_code": "<AWS code>", "operation": "<boto operation>"}. Any other
AWS error code (e.g. InternalServerException) remains an unhandled 500 with the
traceback in the backend log. A failed cross-account role assumption keeps its own
answer: 502 workspace.assume_role_failed. /v1 shares the handler and returns
the same status and code, but its message is a generic per-code sentence
(AWS resource not found, AWS rejected the request as invalid, AWS access denied, AWS is throttling this request, AWS resource conflict) and detail
carries only aws_error_code — the raw AWS text names the deployment's role ARN,
instance id and operation, which stay on the console side of the API-key boundary.
The byoc creation method deploys member-written code. The two zip artifact
kinds (code_zip, container_source) stage their archive here first; the
returned upload_id goes into the create body's spec.byoc.
| Method | Path | Result |
|---|---|---|
POST |
/api/agents/uploads?python_version=PYTHON_3_13 |
perm:agents.deploy — multipart/form-data, single part file, .zip only, ≤250 MiB (≤750 MiB uncompressed, ≤20k entries; zip-slip/absolute paths/symlinks refused). Stores byoc/{workspace_id}/{upload_id}/source.zip + manifest.json in the artifacts bucket → 201 {upload_id, sha256, size_bytes, original_filename, uploaded_by, uploaded_at, entries_count, uncompressed_bytes, detected: {entrypoint_candidates[], has_requirements, has_dockerfile, agentcore_sdk_detected, requirements: {status: ok|failed|skipped, package_count, error}}} — requirements is an upload-time dry resolve of the zip's requirements.txt against the deploy target (linux/aarch64 + the optional python_version, default PYTHON_3_13); failed means the deploy's package stage would fail the same way, skipped (no requirements.txt, resolver timeout ~90 s, uv unavailable) says nothing either way |
GET |
/api/agents/uploads/{upload_id} |
member — the stored manifest (same shape); another workspace's upload_id answers 404 |
Error codes: byoc.invalid_upload (400, missing/non-zip part or empty file),
byoc.invalid_python_version (422),
byoc.upload_too_large / byoc.upload_request_too_large (413),
byoc.zip_invalid, byoc.zip_empty, byoc.zip_entry_unsafe,
byoc.zip_too_many_entries, byoc.zip_uncompressed_too_large (422),
byoc.upload_not_found (404).
The zip's requirements.txt follows the pip file format (backslash
continuations, inline comments, environment markers all honoured); --hash=
options are dropped because the platform re-locks the file against its own
deploy target with fresh hashes. Refused with a clear error: -r/-c
includes, -e/editable installs, local paths, direct URL/VCS entries, index
options (--index-url/--extra-index-url/--find-links — the platform
installs from its own index only), and more than 500 entries.
POST /api/agents with method: "byoc" takes spec.byoc:
{artifact_kind: code_zip|container_source|container_image, upload_id?, image_uri?, entrypoint? (code_zip, default main.py), python_version? (PYTHON_3_10…PYTHON_3_13, default PYTHON_3_13), install_requirements? (default true), invoke_contract? (launchpad_prompt|raw), allowed_models? (1–20 unique Bedrock foundation-model or inference-profile ids)} — the zip kinds require
upload_id, container_image requires a private-ECR image_uri in the
workspace's account+region. system_prompt is optional for this method (it
serves as a description); tools/toolkits/skills/knowledge_bases and protocol
a2a are refused in v1. The server stamps spec.byoc.provenance from the
upload manifest during deploy.
Allowed models. The per-agent execution role scopes bedrock:InvokeModel
to exactly byoc.allowed_models, so user code calling any other model gets
AccessDeniedException at runtime. Entry [0] is the primary model and
must equal spec.model_id: send only allowed_models and the server sets
model_id to the first entry; send both and model_id must be in the list (it
is moved to the front). A spec without allowed_models — including every spec
written before the field existed — behaves as [spec.model_id]. Re-publish
rewrites the role policy, so an edited list takes effect on the next deploy.
The deployer passes the primary id to the runtime as env MODEL_ID and the
full list as env ALLOWED_MODEL_IDS (comma-separated, primary first) — for
either variable, a value already in spec.env wins.
GET /api/agents/{agent_id}/versions is the read-only AWS view behind the agent
detail's VERSIONS & ENDPOINTS panel. It follows every nextToken page of the two
list operations for the agent's resource family and returns an allow-listed
projection — no environment values, artifact locations, execution roles or
authorizer configuration.
| Method | Path | Result |
|---|---|---|
GET |
/api/agents/{agent_id}/versions |
{kind: runtime|harness, resource_id, versions[{version, status, description, last_updated_at}], endpoints[{name, live_version, target_version, status, description, created_at, last_updated_at, failure_reason}], latest_version, ledger_version, canary_endpoints[]} — versions newest first; endpoints with DEFAULT first then by name; latest_version is the highest version AWS reports and ledger_version the one the last Launchpad deploy recorded (Agent.version) — they may differ after an out-of-band update or a canary candidate mint; canary_endpoints lists the stable/treatment names still present. Resource family: zip_runtime/studio/container and imported rows whose spec.discovery.resource_type is absent or runtime → ListAgentRuntimeVersions + ListAgentRuntimeEndpoints; harness and imported rows with resource_type == "harness" → ListHarnessVersions + ListHarnessEndpoints (harness versions carry no description). Never mutates anything |
GET |
/api/agents/{agent_id}/conversions |
member — {source: {id, name, method, status}, conversions: [agent…]}: the Runtime twins converted from this agent (POST …/convert stamps spec.source_harness.agent_id on the new -rt agent), newest first, each in the ordinary agent projection plus its latest deployment. Pure ledger read (no AWS call); deleted twins are omitted; unknown or deleted source → 404 agent.not_found. The assistant's NEXT STEPS reads it to switch to the twin once it is active and to find it again after a reload |
Error codes: agent.not_found (404, unknown id or another workspace's agent),
agent.no_resource (409, the row has no AWS resource to ask about — deploy still
running, failed first deploy, deleted, or a shape that is neither Runtime nor
Harness; message is the human reason the panel shows). AWS ClientErrors map to
the standard 4xx envelope.
The console labels Connections Outbound credentials (出站凭证) and the caller's grants My authorizations (我的授权); paths, fields and error codes keep connection.
A Connection is an AgentCore Identity credential provider (OAuth2 or API key) in
the workspace's token vault; see identity.md for the service-model
findings and the design. Reads are open to members; every mutation requires the
identity.manage agent permission (perm:identity.manage in ROUTE_POLICY).
Secrets (client_secret, api_key) are forwarded to AWS and never stored in the
ledger nor returned.
| Method | Path | Result |
|---|---|---|
GET |
/api/identity/connections |
{connections[{name, kind: oauth2|api_key, vendor, arn, callback_url, client_id, scopes, template, description, created_at, created_by, system, source: system|launchpad|external, status: ready|missing, referenced_by[{type: agent|gateway_target, id, name}]}]} — the token vault joined with the ledger; missing = recorded by Launchpad but gone from the vault |
GET |
/api/identity/connections/templates |
{templates[{id, kind, vendor, fields[], discovery_hint?}]} — the create form's provider templates |
GET |
/api/identity/connections/oidc-sources |
member — {sources[{name, vendor, discovery_url, issuer, derived_from: discovery_url|issuer}]}: OAuth2 Connections whose stored oauthDiscovery yields an OIDC discovery URL (the inbound JWT form's "Choose from a Connection"). System providers and GitHub are left out; the Connection's client id is never returned |
GET |
/api/identity/connections/{kind}/{name} |
one Connection (same shape), re-read from AWS — the callback URL dialog uses it |
POST |
/api/identity/connections/oauth2 |
201 Connection. Body {name, vendor, template?, description?, client_id, client_secret, discovery_url? | issuer+authorization_endpoint+token_endpoint, scopes[], obo?{grant_type: TOKEN_EXCHANGE|JWT_AUTHORIZATION_GRANT, actor_token_content?: NONE|M2M, actor_token_scopes?[]}}. obo (P3) sets onBehalfOfTokenExchangeConfig — CustomOauth2 only (422 identity.obo_vendor_unsupported), refused for Cognito or an IdP whose discovery document omits the grant (422 identity.obo_unsupported); the Connection item echoes it as obo |
POST |
/api/identity/connections/api-key |
201 Connection. Body {name, description?, api_key} |
DELETE |
/api/identity/connections/{kind}/{name} |
{deleted: true}; refused with 409 identity.connection_referenced while an agent spec or a gateway target references it |
GET |
/api/identity/gateway-targets |
{gateway_id, targets[{target_id, name, description, status, status_reasons, source, system, auth, connection, mode, scopes}]} for the workspace gateway |
POST |
/api/identity/gateway-targets |
201 target + warnings[{code, message, detail}] (non-blocking). Body {name, description?, source: openapi|mcp, openapi_schema | mcp_endpoint, connection, kind, mode: as_agent|as_user|obo, scopes[], api_key?{location: HEADER|QUERY_PARAMETER, parameter_name, prefix?}}; an MCP server target takes an OAuth2 Connection only. mode: obo sets grantType: TOKEN_EXCHANGE and needs a CUSTOM_JWT gateway (409 identity.obo_needs_jwt_gateway) and an OBO-configured Connection (422 identity.obo_unsupported). When the Connection's issuer differs from the gateway's JWT authorizer issuer, the target is still created and warnings carries identity.obo_issuer_mismatch {connection, connection_issuer, gateway_issuer}: the IdP must trust the gateway's inbound issuer |
DELETE |
/api/identity/gateway-targets/{target_id} |
{deleted: true}; only Connection-bound, non-system targets |
POST |
/api/identity/oauth/complete |
identity.grant — as_user (3LO) binding leg, called by the /auth/return page. Body {session_id} (the session_id AgentCore Identity appended to the return URL; an opaque one-time capability) → {completed: true, provider, agent_id, agent_name, tool} after CompleteResourceTokenAuth for the user recorded with the session. The caller must be that user. 404 identity.session_unknown (never issued, already used, or dropped by a revoke), 409 identity.session_expired (older than 15 minutes), 403 identity.session_user_mismatch, 409 identity.session_token_unavailable, 409 identity.as_user_requires_user_jwt, 502 identity.session_completion_failed. See identity.md §7.4 |
GET |
/api/identity/grants |
member — the caller's own as_user grants, never another member's: {grants[{connection, agent_id, agent_name, tool, scopes[], status: pending|authorized|revoked, force_reauth, created_at, updated_at, authorized_at, revoked_at}]}, newest first (My authorizations / 我的授权) |
GET |
/api/identity/grants/{connection}/status?agent_id= |
member — {connection, agent_id, status: none|pending|authorized|revoked, force_reauth, authorized_at}; what the Chat auth card polls until the consent completes (none until an ask was recorded). A grant is usable only when authorized with force_reauth: false |
DELETE |
/api/identity/grants/{connection} |
identity.grant — revoke (force re-auth): {revoked: true, provider, agents}; the next call through this Connection carries forceAuthentication=true and asks for consent again. The revocation clears only when a fresh consent completes |
GET |
/api/identity/consent-portal |
member — {gateway_id, portal: {id, name, status, status_reason, portal_url, execution_role_arn, connection, scopes[], audience, created_at, …} | null}, the workspace gateway's Consent Portal read back from AWS every time (gateway_id: null without a gateway) |
POST |
/api/identity/consent-portal |
admin — 202 {gateway_id, portal}. Body {name, description?, connection, scopes[] (default [openid]), audience?, execution_role_arn}: creates the portal for as_user Gateway targets (connection = the inbound IdP, the same issuer as the gateway's JWT authorizer; the execution role is operator-supplied). Not member-grantable: identity.manage does not reach it. 409 identity.gateway_missing / identity.consent_portal_exists, 422 identity.invalid_portal_name / identity.invalid_role_arn / identity.role_account_mismatch / identity.portal_scopes; see identity.md §7.6 |
DELETE |
/api/identity/consent-portal |
admin — {deleted: true}; 404 identity.consent_portal_not_found |
GET |
/api/agents/{agent_id}/identity |
member — {agent_id, name, method, workload_identity{status, name, arn, allowed_return_urls}, inbound{mode: iam|jwt, source}, downstreams[{type, name, tool_type, via: agent|gateway, mode, connection, kind, scopes, connection_status: ready|missing|unbound}]} — the read-only 身份 page |
POST |
/api/agents/{agent_id}/inbound-auth |
agent.deploy — 202 {agent, job_id, deployment_id}. Body {inbound_auth: {mode: iam|jwt, jwt?} | null} (null unpins → workspace default; the key is required and unknown keys are refused with 422 validation.invalid_request, so a mistyped body never starts a redeploy). Re-publishes the stored spec with the pin swapped: UpdateAgentRuntime on the same runtime, new version. 422 agent.inbound_auth_unsupported (JWT on harness / A2A), 422 agent.inbound_auth_invalid, 409 agent.deploy_in_progress |
GET |
/api/identity/inbound-auth/default |
member — {workspace_id, default: {mode: iam|jwt, jwt?}, configured, cognito, cognito_issuer}; configured=false = implicit IAM; cognito is a ready-to-use JWT config for the workspace pool, or null before bootstrap; cognito_issuer is the pool's issuer (the issuer of every token the console, /v1 and evaluation present), or null |
PUT |
/api/identity/inbound-auth/default |
identity.manage — body {mode, jwt?: {discovery_url, allowed_clients[], allowed_audience[], allowed_scopes[], custom_claims[], source_connection?}} → the GET shape. source_connection is display-only and never reaches the authorizer. The discovery document is probed first (422 identity.discovery_unreachable / identity.discovery_invalid). Deployed agents keep their authorizer until redeployed |
Error codes (identity.*): 409 — connection_exists, target_exists,
connection_referenced, system_connection, system_target, target_unbound,
no_gateway; 404 — connection_not_found, target_not_found; 422 —
unsupported_vendor, missing_endpoints, endpoints_custom_only,
invalid_target_name, invalid_openapi, invalid_mcp_endpoint,
target_auth_unsupported, obo_invalid, obo_vendor_unsupported, obo_unsupported,
discovery_unreachable, discovery_invalid; 409 — obo_needs_jwt_gateway. The as_user
consent and Consent Portal codes (session_*, as_user_requires_user_jwt,
consent_portal_*, gateway_missing, …) are listed in
identity.md §7.4. Agent create/redeploy additionally rejects a ToolRef.auth
naming an unknown Connection (connection_unknown), a kind that does not match it
(kind_mismatch), as_user on an api_key Connection (422 from the schema), or obo on a
tool (mode_unsupported; obo runs on a Gateway target).
System-managed presets (see architecture → System-managed presets) are installed, repaired and removed only here. Reads never touch AWS; the install is an explicit, billable operator action that runs the normal deploy pipeline.
| Method | Path | Role | Result |
|---|---|---|---|
GET |
/api/system-agents |
member | `{workspace_id, presets[{key, name, label, description, method, skill_version, installed_skill_version, update_available, status, requirements[{code, message}], name_collision, agent_id, agent_status, error, job_id, deployment_id, deployment_status, model_id, model_source, knowledge_bases[], allowed_tools[], memory, settings{model_id, model_source, max_tokens, reasoning_effort, system_prompt, max_iterations, timeout_seconds, knowledge_bases[]} |
POST |
/api/system-agents/{key}/install |
admin | required JSON body, a partial edit: `{model_id?, model_source?, max_tokens? (1–131072, per model call = bedrockModelConfig.maxTokens), reasoning_effort? (low |
POST |
/api/system-agents/{key}/skill-registration |
admin | no body (the resource is server-selected: the release the stored spec pins, proven against this build and read back from S3) → 200 {preset_key, record{system, record_id, name, type: AGENT_SKILLS, status, version, descriptors, …}, created, changed, submitted, note, skill{name, version, digest, path, files[]}, preset} — registers the active preset's published Skill release as its own Registry record pointing at the immutable system-skills/<name>/<version>-<digest12>/ prefix (no S3 write, no Harness re-publish, the agent's A2A record untouched). created ⇒ a new record submitted for review (never approved here); changed without created ⇒ the descriptor rolled forward to a newer release (DRAFT, review again, recordVersion bumped); neither ⇒ identical release, no-op (approval kept). Idempotent and race-safe (durable clientToken, per-preset lock); the deploy pipeline's register stage performs the same registration on install/repair |
DELETE |
/api/system-agents/{key} |
admin | 202 {agent, job_id, operation: uninstall, attempt, started, preset} — claims the row (uninstalling, optimistic CAS) and queues the teardown job in one commit; simultaneous requests share one job (started: false for the loser); the row keeps its identity until the exclusive (per-agent advisory lock, single host), fenced worker has verified every KB target, the Harness and the dedicated role are gone (per-step progress with exact resource ids on the job, carried into the next attempt only when verified); a failed teardown gets attempt N+1; 409 agent.deploy_in_progress while a deploy runs |
GET /api/system-agents additionally reports skill_registration ({record_id, pending_record_id, status: creating | accepted | registered, release_version, release_digest, path, updated_at} | null, ledger-only; accepted = our create returned
pending_record_id but the read-back has not verified it, record_id stays null
until it does; null when nothing is mapped for the workspace's current registry) and
can_register_skill. Registry records (GET /api/registry/records[/{id}], search, action/update/reimport responses) carry a
server-derived system member — {managed: true, preset_key, label, skill_version, release_digest, path, protected_actions[], admin_actions[]} | null — set exactly when
the workspace ledger maps the record (verified or accepted id) to a system preset's
Skill in the workspace's current registry; it is never read from descriptors or
tags. For such a record PUT, POST …/reimport and DELETE answer
403 registry.system_skill_protected for every caller, POST …/action answers it for
members (administrators may submit/approve/reject/disable), and an ordinary Skill
register/import under the reserved name answers 409 registry.name_reserved.
Skill-registration error codes: system_skill.preset_not_active (409),
system_skill.release_mismatch (409, this build's bundle is not the installed
release), system_skill.bundle_unverified (409, the published S3 release differs from
the snapshot; nothing written), system_skill.foreign_record (409, a same-name Skill
record this platform cannot prove it created — also a lost-create replay the service
did not honour; nothing bound), system_skill.stale_release (409, the caller's release
is older than the installed / verified / intended / remote one, or pins another digest
of the same version),
system_skill.record_deprecated (409, terminal — delete in AWS and register again),
system_skill.readback_mismatch (409), registry.unavailable (503).
Error codes: system_agent.unknown (404), system_agent.workspace_not_ready (409,
detail.requirements[{code, message}]), system_agent.name_collision (409, an
ordinary agent holds the reserved name — never adopted), system_agent.not_installed
(404 on uninstall of an absent preset), system_agent.uninstalling (409 on install
or repair while a teardown job owns the row or its last attempt failed —
detail.job_id/job_status/error), agent.deploy_in_progress (409 on uninstall while
deploying). On the ordinary agent routes a preset answers
agent.system_managed (403, detail.action ∈ redeploy | delete | convert | experiment | canary | promote | …, detail.maintenance_route) before any AWS call;
the experiment and runtime-canary action routes answer the same for rows referencing
a preset; DELETE /api/knowledge-bases/{kb_id} answers kb.attached_to_system_agent
(409, detail.agents) when the KB is mounted on a preset, with or without force;
and POST /api/agents with a reserved name answers agent.name_reserved (409).
Every agent projection carries system: {managed, key, label, skill_version, protected_actions} | null. AgentSpec.allowed_tools (harness only) accepts 1–64
character entries matching *|@?name(/tool)?.
AgentSpec.native_tools is a unique list drawn from shell and file_operations,
defaults to [], and is only supported for Harness. allowed_tools: null derives
runtime selectors from the resolved attachments, Skills and native selections;
deployment always sends allowedTools, including [] for an empty selection.
Explicit patterns remain expert overrides with the existing mounted-KB support.
The console shows preserved overrides and requires an explicit switch back to
selection-derived access before native checkboxes take effect. Presets retain
their server-owned overrides.
The architect assistant (see architecture → Architect assistant (SE-039)) is a
member conversation with the protected aws-agent-solution-architect preset that ends
in an inert proposal for one new managed Harness. Discussion routes are member
(parity with Chat); the approval rides perm:agents.deploy (parity with POST /api/agents) and additionally re-resolves the caller's account, permission and
workspace grant from the database inside its write transaction. Every route is
workspace-scoped and principal-bound (user:<id> / config-admin /
local-operator; usernames are display only): a conversation of another principal —
another member, an administrator, or a re-registered account with the same username —
answers 404 assistant.conversation_not_found. Reads are ledger-only except where
noted.
Conversation detail additionally contains preparation: {revision, knowledge_bases, skills, tools, requirements}. tools contains Registry MCP/Gateway catalog keys
(at most 20); Skills and KBs remain capped at 10 each. Each advisory requirement has id, kind
(knowledge_base | skill | tool | clarification), title, reason,
materials: string[], and required: boolean. Selections and requirements are private
to the conversation owner; they do not grant resource access or approve deployment.
| Method | Path | Role | Result |
|---|---|---|---|
GET |
/api/assistant/architect |
member | {workspace_id, account_id, region, available, reasons[], preset{key, label, status, agent_id, requirements[], can_install}, can_deploy, deploy_requirements[{code, message}], capabilities{shared_memory, kb_gateway}, is_admin, owner, principal} — available ⇔ the preset is active; capabilities says which prerequisites a proposal may bind to (never created here); ledger-only |
GET |
/api/assistant/architect/conversations |
member | {conversations[{id, title, owner, mine, read_only, shared, shared_by, shared_at, turns, turn_in_progress, status, proposal_status, proposal_revision, created_at, updated_at}]} — the caller's own plus every conversation its owner or an admin shared in this workspace (mine: false), newest first (≤ 50); an admin lists every member's conversation of the workspace (≤ 200), another member's unshared one with read_only: true; owner is the creator's display username; each messages[] row of the detail carries author (the sender of a user row) |
POST |
/api/assistant/architect/conversations |
member | body {title?} → 201 conversation detail (below); snapshots the workspace catalog (registry attachables, live gateway ARN + outbound-auth identity per gateway record, S3 content digest per skill, ACTIVE managed KBs, workspace prerequisites — the only AWS reads); 409 assistant.unavailable (detail.preset_status) while the preset is not active |
GET |
/api/assistant/architect/conversations/{id} |
member (owner, or anyone while shared); admin (any, read_only while unshared) |
`{…summary, catalog{fetched_at, tools[{key, kind, name, description, attachable, reason, gateway_arn?, auth_type?, outbound_auth?, url?, record_id}], skills[{key, name, description, path, record_id, content_digest, object_count}], knowledge_bases[{kb_id, name, description}], warnings[], resources{memory_arn, kb_gateway_id, kb_gateway_arn, oauth_provider_arn, execution_role_arn}, target{workspace_id, account_id, region}}, messages[{id, turn, role: user |
PUT |
/api/assistant/architect/conversations/{id}/sharing |
member (owner) or admin | body {shared: bool} → summary. Opens (or closes) the conversation to every member of the workspace, stamping shared_by / shared_at; does not touch updated_at. Only a conversation the caller can already reach (its own, or one already shared; an admin reaches every conversation, so it may share any — that is how a read-only one becomes a collaboration) — another member's private one is a 404 for a non-admin; a collaborator on a shared conversation who is not its owner nor an admin is a 403 assistant.share_forbidden. While shared, every conversation route below — turns, catalog, preparation, proposal edit/reject/approve, evaluation plan and assets — accepts any member under that route's usual permission (re-checked inside each write, so unsharing takes effect immediately); only footprint and DELETE (CLEAR) stay with the owner (404 for a collaborator). An admin's read of an unshared conversation (GET detail, evaluation-plan, evaluation-plan/operations/{id}) never widens a write: every write route answers it 404 until the conversation is shared |
POST |
/api/assistant/architect/conversations/{id}/catalog |
member | re-reads the catalog → {catalog, conversation}; consume the returned conversation because refreshed bindings can advance preparation and proposal revisions |
PUT |
/api/assistant/architect/conversations/{id}/preparation |
member | {expected_revision, knowledge_bases: [kb_id], skills: [catalog_key], tools?: [catalog_key]} → full conversation detail, including preparation. Missing tools preserves existing MCP choices for older clients; [] explicitly removes them. Every list is unique. Validates live resource bindings and creates a new reviewable proposal revision when applicable, retaining evaluation rules for fresh validation rather than broadening them. Refuses a stale preparation revision or an in-flight turn. |
POST |
/api/assistant/architect/conversations/{id}/preparation/skills |
perm:agents.deploy |
{expected_revision, staging_id, selections: [{index}]} → {conversation, results: [{name, ok, key?, error?}]}. Compatibility endpoint for earlier assistant imports; the current console creates new Skills in Registry. Imports server-validated staged bundles into conversation-owned sources and selects successful imports; never accepts a client S3 path. |
POST |
/api/assistant/architect/conversations/{id}/turns |
member | body {prompt} (≤ 100k chars / 300k bytes, and it must fit the request budget with the preamble) → SSE `meta{conversation_id, turn, session_id, agent, omitted_turns} → (tool |
PUT |
/api/assistant/architect/conversations/{id}/proposal |
member | body {content} (the proposal allowlist; unknown outer members → 422) → {proposal} — a new revision (source: member, unique monotonic number), never a mutation; invalid content is stored as invalid with validation_errors, never corrected; 413 assistant.proposal_too_large above 64 000 serialized bytes (nothing stored; the normalized stored content is re-checked against the same cap); 409 assistant.conversation_full at 50 revisions. Every assistant write is also bounded at ingress: 413 assistant.request_too_large above 512 000 received bytes |
POST |
/api/assistant/architect/conversations/{id}/proposal/reject |
member | body {revision} → {proposal} with status: rejected (non-executable); a conditional transition — 409 assistant.proposal_stale for a non-current revision, 409 assistant.proposal_already_approved (detail.approval) when the revision was executed meanwhile |
POST |
/api/assistant/architect/conversations/{id}/proposal/approve |
perm:agents.deploy |
body {revision, content_hash} → 202 {proposal, agent, job_id, deployment_id, started: true} when this call claimed the revision, claimed the agent name (shared with POST /api/agents) and created the ordinary agent + deployment + deploy_agent job with their ids on the proposal in one commit; 200 … started: false with the recorded outcome for a repeated, concurrent or historical (already approved, even if newer revisions exist) request — and a still-queued job with no live worker is re-woken. Refusals, all before any write: 401 auth.required / 403 auth.permission_required / 403 workspace.forbidden (re-resolved from the database at the claim, after the live catalog read), 409 assistant.proposal_stale (unknown revision / hash differs / changed while approving), 409 assistant.proposal_not_approvable (invalid, rejected, superseded), 409 assistant.workspace_not_ready, 409 assistant.proposal_invalid (live catalog no longer has a referenced resource or prerequisite — e.g. the KB gateway), 409 agent.name_reserved, 409 agent.name_exists (atomic — one of two racing creators), 409 assistant.bindings_changed (detail.changed[] — a key resolves to a different URL, gateway auth identity, skill content, memory or KB gateway than reviewed), 502 assistant.catalog_unavailable (live catalog unreadable and no winner exists); every already-approved answer re-validates the caller first |
The turns body also accepts
evaluation_plan_repair: {plan_revision: <positive integer>, plan_hash: <64 lowercase hex>}.
The reference is strict and resolves the current saved invalid plan within the owned
conversation/workspace. The server appends its validation errors, content and source/latest
proposal context to the prompt; the ordinary SSE and inert revision lifecycle is unchanged.
Before streaming: 409 assistant.evaluation_repair_stale for a changed reference/source,
409 assistant.evaluation_repair_not_needed for a plan that is no longer invalid,
409 assistant.evaluation_plan_source_invalid when the latest proposal needs correction,
and 413 assistant.evaluation_repair_too_large when the complete context exceeds the
ordinary prompt/replay budgets. No evidence is truncated. The console explicitly prepares
a plan from the returned usable new proposal and displays its validation result for review;
repair creates no evaluation assets and does not approve or deploy the proposal.
Catalog tool entries optionally carry runtime_tools: string[] | null, the exact
Harness names usable in evaluation rules. Remote MCP names come from complete,
bounded read-only tools/list discovery; Gateway names come from approved Registry
descriptors. null means discovery is unavailable and a literal positive allowlist
cannot be verified. Resource selectors (mcp:…, gateway:…, builtin:…) are
rejected inside code-rule tool fields. Positive allowlists must include mounted
Skill/KB support calls; plan save and approval also reject names outside the selected
catalog. Refresh the conversation catalog before preparing a replacement draft.
The catalog also exposes runtime_builtin_tools for native Harness shell and
file_operations. A proposal's optional native_tools list selects these capabilities;
omission means no native tools. The selection is visible in review and bound to the
approval hash (resources.tool_access_policy=selected-v1). Evaluation rules cannot
grant an unselected native capability or silently expand an approved allowlist.
A proposal is {id, conversation_id, revision, source: model|member, status: draft|invalid| approved|rejected|superseded, content, content_hash, bindings, validation_errors[], created_by, created_at, approval, rejected_by, rejected_at}. content is the
allowlisted object {version: 1, name, model_id, model_source, system_prompt, tools[key], skills[key], knowledge_bases[kb_id], memory: disabled|workspace, max_iterations, timeout_seconds, summary, requirements_baseline[], assumptions[], manual_tasks[], golden_tests[{id, input, expected_response, expected_tools[], forbidden_behavior, pass_criteria, evaluator, source}], evaluator_recommendations[]}; bindings is the
resolved {name, method, model_id, model_source, tools[ToolRef], skills[s3 path], knowledge_bases[KnowledgeBaseRef], memory{short_term, long_term, memory_id}, max_iterations, timeout_seconds, resources{gateways{<gateway_id>: {gateway_arn, gateway_name, record_id, auth_type, outbound_auth}}, remote_mcp{<name>: {url, record_id}}, skills{<key>: {record_id, path, source_prefix, content_digest, object_count, total_bytes}}, kb_gateway{gateway_id, gateway_arn, oauth_provider_arn, url, authorizer_type, authorizer} | null, memory{mode, arn}, execution_role_arn}} (null when invalid; outbound_auth is an identity — provider
ARN, grant type, scopes — never a credential value); content_hash = sha256 of
canonical {content, bindings}. The deploy job carries {content, bindings} on its
payload and re-checks them at job entry (drift → the job fails before any stage). The
Observability routes (/api/observability/sessions*, /traces*, …/evaluate) hide
another principal's assistant sessions and answer 404 observability.session_not_found / observability.trace_not_found for their details. approval is
{approved_by, approved_at, agent_id, agent_name, agent_status, agent_error, deployment_id, job_id, job_status} | null; the job and agent are ordinary rows readable
through GET /api/jobs/{id} and GET /api/agents/{id}. The generic invoke entrances
(POST /api/chat/{id}, POST /api/agents/{id}/invoke, /v1 …/invoke[-stream]) answer
404 chat.session_not_found for an assistant turn's session_id on a system-managed
agent.
Private to the conversation owner (foreign principal / workspace → 404 assistant.conversation_not_found, administrators included). Preparing/editing is
member; creating and cleaning up is admin and owner. Reads are ledger-only.
Each newly created code evaluator receives a single-rule Lambda package and its own
resource chain. Operation resources may carry code_group, identifying the evaluator
key that owns the Lambda/role/log group/permission/grant. New plan summaries count
these chains individually; materialized historical plans use their recorded resource
counts. Existing single-function operation keys remain readable.
| Method | Path | Role | Result |
|---|---|---|---|
GET |
/api/assistant/architect/conversations/{id}/evaluation-plan |
member | {plans[…], operations[…], disclosure} |
POST |
…/evaluation-plan/prepare |
member | body {revision} (a shape-valid proposal revision, approved or not) → 201 {plan, plans, operations, disclosure} — the platform draft as a new plan revision; 409 assistant.proposal_stale / 409 assistant.evaluation_plan_source_invalid; no side effects |
PUT |
…/evaluation-plan |
member | body {content} (the plan contract; content.source_revision names the proposal revision) → {plan, …} — a new revision, draft or invalid with validation_errors (never corrected); 413 assistant.evaluation_plan_too_large above 160 000 bytes |
POST |
…/evaluation-plan/materialize |
perm:agents.deploy (owner) |
body {plan_revision, plan_hash, acknowledge_disclosure: true} → 202 {operation, started: true} when this call atomically claimed the plan (still draft, this hash, newest revision; worker launched), 200 … started: false for a repeated / concurrent request (same operation). 422 assistant.disclosure_required, 409 assistant.evaluation_plan_stale (unknown revision / hash differs / edited, superseded or claimed meanwhile), 409 assistant.evaluation_plan_not_approvable (invalid / superseded), 409 assistant.evaluation_plan_invalid (no longer validates against its proposal), 409 assistant.workspace_not_ready, 409 assistant.execution_role_untrusted (grant requested but the workspace execution role is not platform-tagged); the caller (admin + owner) and the workspace identity are re-resolved from the database inside the claim and pinned on the operation |
GET |
…/evaluation-plan/operations/{operation_id} |
member | {operation} — ledger only, no AWS call |
POST |
…/evaluation-plan/operations/{operation_id}/retry |
perm:agents.deploy (owner) |
{operation, started} — resumes the persisted intents of a partial / failed operation (same tokens/requests); 409 assistant.evaluation_assets_new_plan_required rejects a legacy shared or unknown Lambda package before queuing or consuming an attempt; 409 assistant.evaluation_assets_exhausted after 5 attempts |
POST |
…/evaluation-plan/operations/{operation_id}/lambda-revision-review |
perm:agents.deploy (owner) |
body {plan_hash, expected_created_revision_id, expected_current_revision_id, cloudtrail_event_id, reason} (extra="forbid", non-empty reason) → {operation, review, started} — SE-049 reviewed recovery of exactly one conflict: the RevisionId CreateFunction answered with moved while the function was still provisioning (Pending → Active) and the worker refused to publish (resources[lambda_function].review.kind = initial_revision_changed). The server reads the nominated CloudTrail event itself (LookupEvents by EventId; the client's JSON is never trusted) and requires exactly one successful CreateFunction20150331 record from lambda.amazonaws.com in the pinned account/region whose request fields equal the recorded request and whose response carries the recorded FunctionArn, the expected created RevisionId, the reviewed CodeSha256, state = Pending / stateReasonCode = Creating and a lastModified; then the settled $LATEST must be that answer plus only the lifecycle transition — Active / LastUpdateStatus Successful, exactly expected_current_revision_id, the same LastModified, every approved field and every optional security-relevant member (environment, layers, VPC, KMS, file systems, dead-letter, signing, architecture, tracing, logging, ephemeral storage, SnapStart, image config, runtime version) equal — with $LATEST the only version, no alias, no resource policy, no reserved concurrency, and the operation's role / log group still carrying their recorded identity. On success an append-only review entry (reviewer, reason, event id / time / request id, verified fields, old and new snapshot, exact plan binding) is stored on the resource (reviews[]; the original CreateFunction evidence is never overwritten), the baseline revision_id / settled_revision_id move to the reviewed value, the Lambda conflict and its blocked dependents (lambda_permission, role_grant, code evaluators) are re-queued and the ordinary worker resumes — its PublishVersion still carries both preconditions, so a later change fails there. No cloud write happens in this route. The exact same request is idempotent (started: false, the recorded review, no CloudTrail read); a different one after a review is 409 assistant.lambda_revision_review_stale. Refusals (nothing recorded, nothing written): 422 assistant.lambda_revision_review_reason_required, 409 assistant.evaluation_plan_stale (hash), 409 assistant.lambda_revision_review_not_applicable (operation not partial/failed, function not an owned accepted create blocked solely by the pre-publish RevisionId drift — a lost create, a published version, a publish intent or a re-pinned baseline — or another unrelated open outcome), 409 assistant.lambda_revision_review_stale (created RevisionId / transition mismatch, prior different review), 409 assistant.lambda_revision_review_unverified (detail.fields names the differing members; also 0 / 2+ / malformed events, an unterminated event history, InProgress / Failed update status, extra versions / aliases / policy / concurrency, changed role or log-group identity), 409 assistant.evaluation_assets_running, 409 assistant.evaluation_assets_exhausted, 409 assistant.evaluation_assets_stopped (approver or workspace identity changed). The four sides are compared losslessly along the installed Lambda model: the recorded request ↔ the event's requestParameters (member for member; an extra member such as an environment is not approved), the immutable accepted answer (create_response, when present) ↔ the event's responseElements, that answer ↔ the current $LATEST (every non-lifecycle member present, absent and equal alike — an extra DurableConfig / TenancyConfig / CapacityProviderConfig / MasterArn or any member unknown to the platform is a difference), and every member of the answer must be fixed by the request or be a documented service default (PackageType Zip, x86_64, PassThrough tracing, 512 MB ephemeral storage, SnapStart off, text logging to /aws/lambda/<name>, a region-local runtime-version ARN). Only structure member names are casing-normalized; data-map keys / values (environment variables, tags) and empty strings are content; presence is compared with an explicit absent sentinel (a member present as null or with a wrong type is a difference, never absence), every side is type-validated against the installed Lambda model before comparison (a malformed value is refused, not normalized), the only absent-versus-empty equivalence is a documented envelope in its well-formed empty shape (environment: {} / {"Variables": {}}, Layers / FileSystemConfigs [], an all-empty typed VpcConfig), and CodeSize is retained on the accepted answer and compared across answer, event and current function. Dependencies are re-compared with the recorded snapshot (RoleId / ARN / trust / inline policy / tags; log-group creationTime / ARN / retention / tags), and a resource policy is proven absent only by a NotFound — an empty or unreadable document is refused. The review's conditional UPDATE binds every value the review relied on as predicates of that one statement — the operation's status / lease token / attempts / plan id / plan revision / plan hash / owner / approver / exact pinned JSON / exact intents JSON, the approved plan row (id / revision / status / hash column and the exact JSON of the validated content, whose canonical hash was checked), the conversation owner (== the operation's recorded owner == the caller), the workspace account / region / role / external id and its exact resources JSON (execution role included), and the approver's and the reviewer's active, unexpired administrator rows — with the caller re-resolved from the database inside the host lock, so a change committed by another session up to that statement makes it a no-op (409 assistant.evaluation_assets_stopped). The verified state is persisted as reviewed_baseline on the resource and the resumed worker re-validates it immediately before its first mutation (whole configuration + tags, dependencies, $LATEST the only version, no alias, policy absent, no reserved concurrency) — an externally published same-code version, a foreign alias / policy / concurrency or any configuration drift is a conflict, never adopted or overwritten, and is not reviewable again. Independently of a review, the ordinary worker refuses to adopt a same-digest version after a PublishVersion refused on its first dispatch (only a lost answer of its own dispatch reconciles) and never overwrites a reserved concurrency it did not set. Limitations: CloudTrail is positive evidence for a human review, not proof that no other write happened (event history is eventually consistent and lists only what was recorded); the console needs cloudtrail:LookupEvents in the workspace role; the external-administrator check→write window remains one call wide |
DELETE |
…/evaluation-plan/operations/{operation_id}/assets |
perm:agents.deploy (owner) |
{operation} — deletes exactly the owned cloud artifacts whose identity still matches (evaluators first; grant / function / log group / role only once no owned evaluator remains), one checkpoint per effect; the local Dataset stays; 409 assistant.evaluation_assets_running while a worker is live, 409 assistant.evaluation_assets_stopped when the approver or workspace identity changed |
GET |
/api/assistant/architect/conversations/{conversation_id}/footprint |
member (owner) | What CLEAR would remove: {agents[{id,name,status,method}], canaries[{id,name,kind,status,agent_id,running_action,pending,resources{ab_test,gateway,endpoints[],online_evaluations}}], experiments[{id,name,agent_id,status,pending}], operations[{id,status,plan_revision,dataset_id,cloud_resources}], datasets[{id,name,item_count,cloud}], blockers[{kind: turn|preparation|operation|job|canary|experiment, id, reason}], required_permissions[], can_clear, purge{job_id,status,error,blocker,step,updated_at}|null, turns, proposals} — canaries / experiments are those run on the conversation's Agents (pending = cleanup not run or left resources); agents also keeps one a failed clear already marked deleted but has not read back gone; required_permissions lists agents.deploy when cloud assets are involved and agents.delete when an Agent is; can_clear = the caller holds them all; purge = the newest clear job. Ledger read only |
DELETE |
/api/assistant/architect/conversations/{conversation_id} |
member (owner) + every required_permissions key |
A transcript-only conversation is deleted inline: 200 {deleted: true, conversation_id, operations_cleaned: [], datasets: [], agents: [], canaries: [], experiments: []}. Anything else is a durable clear job: 202 {deleted: false, job_id, status, …} — poll GET /api/jobs/{job_id} (payload.step, payload.result, payload.blocker on failure). In dependency order, each step read back on AWS before the next: canaries on the Agents (stop + act_cleanup, then wait until A/B test, online evaluations, gateway and both named endpoints are gone) → experiments → the fenced cleanup of every cleanable evaluation-assets operation → the local Datasets (a copy synced to AWS by hand stays) → every Agent (resource delete, wait until not found, its execution role, its own A2A Registry record, ledger) → canary candidate zips → the ledger rows. Refusals before anything is deleted: 409 assistant.conversation_busy (a turn, import, operation, deployment job or canary / experiment action is live), 403 auth.permission_required. A live job is returned again by a repeated DELETE; while it runs conversation writes answer 409 assistant.conversation_clearing and the Agents' canary / experiment / redeploy / convert / delete routes 409 assistant.agent_clearing. A job that cannot finish ends failed with payload.blocker.code one of assistant.purge_canary_incomplete, purge_experiment_incomplete, conversation_assets_remain, purge_still_deleting, purge_delete_failed, purge_agent_dependency, purge_agent_conflict, purge_agent_busy, purge_role_remains, purge_dependency_busy, purge_unowned, purge_step_failed (or a refusal's own code); the conversation is kept and the next DELETE resumes from the verified checkpoints |
A plan is {id, conversation_id, proposal_id, source_revision, source_content_hash, revision, source: platform|member|model, status: draft|invalid|approved|superseded, content (scenarios carry review_required; a legacy draft is invalid until every scenario is confirmed or blocked), content_hash, validation_errors[], summary{scenarios, blocked_golden_tests, evaluators_by_kind, cloud_evaluators, lambda_functions, iam_roles, role_grants, unresolved_recommendations} | null, created_by, created_at, operation_id}. An operation
is {id, conversation_id, plan_id, plan_revision, plan_hash, proposal_revision, approved_by, account_id, region, pinned{workspace_id, account_id, region, role_arn, execution_role_arn, execution_role_id?}, status: queued|running|succeeded|partial|failed| cleaning|cleaned, attempts, max_attempts, dataset_id, error, resources[{kind: dataset| lambda_role|log_group|lambda_function|lambda_permission|role_grant|evaluator|existing, key, plan_key?, name, status: pending|accepted|ready|failed|conflict|blocked|skipped| retained|unknown|delete_pending|deleted|delete_failed, definition?, error, digest?, rules_digest?, reference_dependent?, owned?, recovered?, review?, reviews?, attempts, result, cleanup?, link?}], created_at, updated_at, running, requires_new_plan}. requires_new_plan is derived
from the recorded Lambda packages without AWS calls. When true, the console replaces
retry with a replacement-plan action and withholds evaluation next steps. This action
copies the saved plan's exact content through PUT …/evaluation-plan, preserving
member edits, scenarios and the source proposal binding; it creates only a new draft.
Review and explicit materialization approval remain separate, and the old operation
and its resources remain historical. A lambda_function result carries revision_id (the
current baseline the worker fences its writes on), initial_revision_id and the immutable
allowlisted create_response (State, StateReasonCode, LastModified, RevisionId, …)
plus request_id as CreateFunction answered them, settled_revision_id once the first
initialization is settled, and revision_history[]; review is the review-required marker
(kind, observed_revision_id, observed_last_modified, resolved_by?) and reviews[]
the append-only audit entries — no CloudTrail actor or token is ever stored or returned.
revision_history also records lambda_permission_added after a successful, verified
permission write advances the published version's cleanup snapshot. It retains the
before/after configuration revisions and modification times, policy revisions and request ID; $LATEST
and original creation evidence remain unchanged. Historical mismatches and lost
write responses remain review-required rather than being silently adopted.
POST /api/eval/runs answers
422 run.judge_needs_ground_truth for a managed reference-driven code evaluator whose
scope lacks the reference (a simulated persona item reports <scenario>/simulated turns lacks expected_response — its turns are generated at run time), 422 run.evaluator_unverifiable when a selected custom evaluator cannot be read (unknown
needs are never treated as verified; nothing downstream runs) and 422 run.evaluator_not_found when it does not exist in the workspace; Observability SCORE NOW answers
422 observability.evaluator_needs_ground_truth before any Evaluate call; online evaluation
refuses such evaluators (…evaluator_unsupported). A cloud/existing evaluator mapped to a
subset of golden tests is refused at plan validation (selection is global). link is the existing console
deep link (/evaluation?view=datasets&ds=… / ?view=evaluators&ev=…). The ordinary
DELETE /api/eval/evaluators/{id} answers 409 evaluator.managed_by_operation
(detail.operation_id) for an evaluator an operation owns. GET /api/assistant/architect
gained can_materialize_evaluation_assets (= the caller holds agents.deploy).
GET /api/registry/records/{record_id}/live-agent-card is the LIVE CARD read in
the Registry drawer's AGENT CARD block: the A2A card the runtime behind the record
serves right now, next to the card the record stored at deploy time. It is an
on-demand data-plane call (GetAgentCard), made only when the operator asks —
never on drawer open — and nothing is persisted.
| Method | Path | Result |
|---|---|---|
GET |
/api/registry/records/{record_id}/live-agent-card |
{agent_id, runtime_arn, status_code, card, diff} — card is the JSON document the runtime serves (GetAgentCard.agentCard, what an A2A client reads at /.well-known/agent-card.json), status_code is GetAgentCard.statusCode; diff = {identical, fields[{field, record, live}], skills_only_in_live[], skills_only_in_record[]} comparing name/url/version/protocolVersion and the skill id sets against the record's descriptors.a2a.agentCard.inlineContent (description, capabilities and the platform metadata block are not compared). The route resolves record → ledger agent (Agent.registry_record_id, same workspace, not deleted) → Agent.arn server-side; the browser never supplies an ARN. No runtimeSessionId is sent; the session AWS opens to serve the card is ended with StopRuntimeSession fail-soft (a stop failure is logged, the card is still returned) |
Error codes, all decided on the ledger before AWS is called:
registry.record_not_deployed (404, no Launchpad agent owns the record),
registry.record_not_a2a (409, the agent's spec.protocol is not a2a),
registry.agent_not_ready (409, the agent is not active or has no runtime ARN
yet). A data-plane ClientError maps to the standard 4xx envelope (aws.not_found,
aws.access_denied, aws.throttled, …); a runtime-side failure with no mapping
(RuntimeClientError) is registry.live_card_failed (502) with
detail.aws_error_code — never a bare 500. No IAM change: the console's role
already carries bedrock-agentcore:*.
The Registry page shows the same registry from two sides. The publisher list
(GET /api/registry/records, control plane ListRegistryRecords) is what the
operator manages: every record in every state. The consumer view
(?view=discoverable) is what a consumer or agent with data-plane access actually
sees — the GA discovery API ListDiscoverableRegistryRecords. Records in the first
list but not in the second are the ones approval has not exposed; the console chips
them NOT DISCOVERABLE once both lists are known. Read-only; nothing is persisted.
| Method | Path | Result |
|---|---|---|
GET |
/api/registry/records/discoverable?type= |
{records[{record_id, name, display_name, description, type, descriptor_types[], status, status_reason, version, created_at, updated_at}], count} — data-plane ListDiscoverableRegistryRecords(registryId=<workspace registry>, maxResults=100) paginated to completion with nextToken; type (optional) narrows with filters=[{name: "recordType", values: [<GA type>]}] and accepts the platform (A2A/MCP/AGENT_SKILLS) or GA (agent/mcp/skill) name; type in the rows is always the platform name. Summaries never carry descriptors — read GET /api/registry/records/{record_id} for the payload. count = number of rows |
Error codes: registry.bad_type (422, unknown type), registry.unavailable (503,
the workspace has no registry). AWS ClientErrors map to the standard 4xx envelope
(aws.access_denied, aws.throttled, …), never a bare 500. Route policy: MEMBER,
like the other registry reads.
These /api routes back the authenticated console. They are not part of the
public /v1 agent invocation contract.
| Method | Path | Result |
|---|---|---|
GET |
/api/governance/gateways |
Live MCP Gateway inventory |
GET |
/api/governance/gateways/{id} |
Targets (each with kind: {protocol, variant}), actions + actions_uncovered_targets, Registry, Engine, IAM, and attachability detail |
POST/DELETE |
/api/governance/gateways/{id}/manage |
Add/remove only Launchpad management tags |
GET |
/api/governance/gateways/{id}/registry-preview |
Gateway-level record diff and legacy matches |
POST |
/api/governance/gateways/{id}/registry-import |
Create/reuse/update and submit; never approve |
POST |
/api/governance/gateways/{id}/retire-legacy-records |
Explicit retirement after Gateway record approval |
POST |
/api/governance/gateways/{id}/engine |
Create/adopt and attach an Engine in selected mode (ENFORCE default) |
GET/POST |
/api/governance/gateways/{id}/policies |
List or create LOG_ONLY policies |
PUT |
/api/governance/gateways/{id}/policies/{policy_id} |
Update LOG_ONLY or create an ACTIVE-policy candidate |
POST |
/api/governance/gateways/{id}/policies/{policy_id}/promote |
Evidence-gated activation/cutover |
POST |
/api/governance/gateways/{id}/policies/{policy_id}/rollback |
Audited snapshot/candidate rollback |
POST |
/api/governance/gateways/{id}/mode |
Gateway LOG_ONLY/ENFORCE transition |
POST |
/api/governance/gateways/{id}/generations |
Start NL → Cedar generation for review only |
GET |
/api/governance/gateways/{id}/generations/{generation_id} |
Poll generation status and read draft assets |
GET |
/api/governance/gateways/{id}/decisions |
AWS decision projection or explicit unavailable state |
GET |
/api/governance/gateways/{id}/rate-limits |
{rate_limits: [...]} — every Gateway rate limit (all nextToken pages); works on any Gateway |
POST |
/api/governance/gateways/{id}/rate-limits |
Create a rate limit → 201 with the created record; managed Gateways only |
PUT |
/api/governance/gateways/{id}/rate-limits/{rate_limit_id} |
Replace entries (+ optional description); dimensionKeys are immutable → 422 |
DELETE |
/api/governance/gateways/{id}/rate-limits/{rate_limit_id} |
Delete → {deleted: true, id, status} |
POST |
/api/governance/gateways/{id}/targets/{target_id}/synchronize |
SynchronizeGatewayTargets for one dynamic MCP-server target → 202 with the target projection (status = SYNCHRONIZING, same kind as the detail); managed Gateways only (409 governance.gateway_not_managed); non-synchronizable target → 409 governance.target_not_synchronizable, detail.reason ∈ not_mcp_server, static_tool_schema, pending_auth, synchronizing, not_ready; journaled as target.synchronize |
GET |
/api/governance/gateways/{id}/audit |
Immutable local change journal |
GET |
/api/governance/operations/{operation_id} |
Async operation status |
Every target in the gateway detail and in the synchronize response is the same
projection {id, name, status, status_reasons, description, kind, listing_mode, last_synchronized_at, synchronizable, not_synchronizable_reason}. kind is
{"protocol": "mcp" | "http" | "inference" | "unknown", "variant": <union key> | null} — the TargetConfiguration member AWS set (mcp/lambda, mcp/mcpServer,
mcp/openApiSchema, http/passthrough, http/agentcoreRuntime,
inference/provider, …); an empty configuration is unknown/null and an
unrecognized member is protocol: <key> / variant: null. The detail also carries
actions_uncovered_targets: [name, …] — the http / inference targets, which
have no tool schema and therefore never appear in actions.
Policy and Gateway mutations return 202:
{"operation": {"id": "...", "status": "pending", "operation": "policy_create"}}The rate-limit routes are synchronous — no operation to poll. A rate limit
is {id, gateway_id, description, dimension_keys, entries, status, created_at, updated_at} with status ∈ CREATING | ACTIVE | UPDATING | DELETING. Create
takes:
{
"dimension_keys": ["targetName", "$.context.jwt.sub"],
"entries": [
{"dimensions": {"targetName": "office-facts", "$.context.jwt.sub": "*"},
"requests": [{"rate": 10, "period": "second"}],
"tokens": [{"rate": 5000, "period": "minute"}]},
{"dimensions": {"targetName": "*", "$.context.jwt.sub": "*"},
"requests": [{"rate": 60, "period": "minute"}]}
],
"description": "per-target RPS with a default bucket"
}Update takes entries (replace semantics) and optional description. Validation
runs before any AWS call and answers 422 governance.rate_limit_invalid with
detail.reason ∈ dimension_keys_count | dimension_key_unknown | dimension_key_duplicate | entries_count | entry_dimensions_mismatch | entry_dimension_empty | wildcard_not_trailing | entry_no_metric | rate_config_count | rate_out_of_range | period_not_allowed | description_too_long | dimension_keys_immutable: 1–10 keys from targetName,
toolName, qualifiedModelId, $.context.jwt.<claim>,
$.context.iam.principal, $.context.iam.sourceIdentity; 1–1000 entries whose
dimensions carry exactly the parent keys; * only in trailing positions; at
least one metric per entry; rate 0–10 000 000; requests per
second/minute, tokens per minute only, connections per second only;
description ≤ 512 chars. Mutations on an unmanaged Gateway answer 409 governance.gateway_not_managed; a duplicate dimension-key set or a busy Gateway
is AWS ConflictException → 409 aws.conflict. Every mutation is journaled in
the audit route as rate_limit.create / rate_limit.update /
rate_limit.delete (before = prior record or {}, requested = payload,
after = AWS response, status succeeded/failed).
Generation start returns
{"operation": …, "generation_id": …, "status": …}; a generated asset is only
a draft for the editor and never activates a policy.
Poll the operation route until succeeded, failed, partial, or
interrupted. interrupted means a restart could not prove the AWS effect and
the operation must be retried explicitly — the backend never replays it.
Mutation requests carry the live timestamps and confirmations that apply to the
operation:
{
"expected_gateway_updated_at": "2026-07-16T09:00:00+00:00",
"expected_policy_updated_at": "2026-07-16T09:01:00+00:00",
"acknowledged_gateway_ids": ["gw-a", "gw-b"],
"confirmation_name": "finance-gateway",
"override_reason": null
}Common conflict codes are governance.gateway_not_managed,
governance.concurrent_change, governance.shared_engine_changed,
governance.iam_preflight_failed, governance.evidence_required,
governance.policy_engine_deleted, and
governance.registry_record_not_approved.
When a Gateway still references a Policy Engine that was deleted out-of-band,
reads report the reference with policy_engine.missing = true and
status = "DELETED" instead of failing, policy mutations answer
409 governance.policy_engine_deleted, and POST .../engine treats the
reference as unattached: it creates a new Engine, attaches it in the selected
mode, and records the replaced ARN on the operation.
Every list and detail response of a user-managed resource carries two nullable fields recording who created it through the console:
{"created_by": "3f2a…", "created_by_name": "ada"}created_by is the creator's users.id — NULL for the config admin and the
auth-disabled operator (no users row); created_by_name is the username at
creation, display only. Both are NULL on rows that predate the field and on
resources created outside the console or by a path with no human principal
(Skill Lab sample task sets, the bootstrap memory, experiment/canary-owned online
configs, built-in and third-party evaluators). They are always server-set: a
created_by / created_by_name in a request body is ignored.
| Resource | Routes carrying the fields |
|---|---|
| Knowledge base | GET / POST /api/knowledge-bases, GET / PATCH /api/knowledge-bases/{kb_id} |
| Memory resource | GET / POST /api/memory/resources, GET / PUT /{memory_id}, POST /{memory_id}/adopt — an adopted memory carries the adopter's name with created_by: null |
| Dataset | GET / POST /api/eval/datasets (+ upload, from-sessions, PUT) — a pipeline run's new dataset records whoever started the run; an architect-materialized one the plan approver |
| Pipeline | GET / POST /api/eval/pipelines, GET / PUT /{id}, POST /{id}/run |
| Evaluation run | GET / POST /api/eval/runs, GET /api/eval/runs/{id}; online report rows (GET /api/eval/online/{id}/reports) |
| Online config | GET / POST /api/eval/online, GET /api/eval/online/{config_id} |
| Agent | GET / POST /api/agents, GET /api/agents/{id}, POST /{id}/convert (the converter creates the twin), conversions, /api/system-agents rows, the architect approval reply (the approver); discovered runtimes and presets carry null — owner stays a provenance label |
| Evaluator | GET /api/eval/evaluators, GET /{id}, POST (the create reply) — an architect-materialized one records the plan approver |
| Experiment / canary | GET / POST /api/experiments, GET /{id}; GET / POST /api/runtime-canaries, GET /{id} |
| Skill Lab | job and task-set list / detail / create, POST /jobs/{id}/import-taskset (the importer creates the set) |
An admin chooses per workspace whether members see each other's resources:
| Method | Path | Role | Body / response |
|---|---|---|---|
GET |
/api/workspaces/{workspace_id}/settings/visibility |
admin | {"resource_visibility": "shared" | "isolated"} — shared when never set |
PUT |
/api/workspaces/{workspace_id}/settings/visibility |
admin | body and response {"resource_visibility": "shared" | "isolated"}; any other value → 422 validation.invalid_request; a member → 403 auth.forbidden; an unknown workspace → 404 workspace.not_found |
Both target the path parameter (not X-Workspace) and work on a workspace whose
bootstrap has not finished. Every workspace row returned by GET /api/workspaces
and the admin workspace routes carries resource_visibility. A switch takes
effect on the next request and changes no data.
Under isolated, a member sees a resource of the kinds in the table above iff
its created_by is their own users.id, is null (creator unknown — legacy rows,
samples, the config admin, resources made outside the console), or is the
users.id of an account whose role is currently admin. Admins see
everything. Built-in / third-party evaluators and the bootstrap memory are always
visible; a cloud dataset follows the local datasets linked to it (visible when any
is; one with no local row is shared), and an experiment/canary-owned online config
(exp_<id8>_… / can_<id8>_…) follows its experiment or canary.
A hidden resource is absent everywhere, with the same answer as a missing id:
| Surface | Hidden behaves as |
|---|---|
| Lists and pickers | omitted — filtered before paging, so GET /api/eval/runs total and the 20-row experiment / canary lists count only visible rows; GET /api/memory/resources drops a hidden managed memory entirely (it is not listed as detected) |
| By-id routes | the kind's not-found: kb.not_found, memory.not_managed, dataset.not_found (local and cloud), pipeline.not_found, run.not_found, online_eval.not_found (incl. /results; a hidden report run → online_eval.report_not_found), aws.not_found (custom evaluator GET/PUT/DELETE), experiment.not_found, canary.not_found, skill_lab.job_not_found, skill_lab.taskset_not_found |
| Create-time references | as for a missing id: a run on a hidden dataset → 404 dataset.not_found, with a hidden evaluator → 422 run.evaluator_not_found; a hidden evaluator on an online config / experiment gateway / canary → 400 {online_eval,experiment,canary}.evaluator_unsupported; SCORE NOW (POST /api/observability/sessions/{id}/evaluate) → 404 aws.not_found; a pipeline output / from-sessions / experiment or canary traffic dataset → 404 dataset.not_found; an experiment recommend_source_run_id → 404 experiment.recommend_source_not_found; a canary source_experiment_id → 400 canary.source_experiment_invalid; a Skill Lab job's task set → 404 skill_lab.taskset_not_found |
| Aggregates | GET /api/overview eval_pass_rate / eval_runs and GET /api/overview/online-quality cover only visible runs / configs (the quality tile is cached per viewer) |
| Architect catalog | lists only the KBs the conversation's owner can see |
Internal references are untouched: a visible resource that references a hidden one (an admin's experiment replaying a member's dataset, a pipeline appending to its configured dataset) keeps working.
Agents follow the same rule on the agent's created_by (discovered runtimes and
system presets are creator-unknown, hence shared):
| Surface | Hidden agent behaves as |
|---|---|
| Lists and pickers | omitted from GET /api/agents (every console picker), conversion twins and GET /v1/agents (for a member-minted key) |
| By-id routes | 404 agent.not_found on /api/agents/{id} (+ conversions, versions, identity, redeploy, inbound-auth, convert, invoke, DELETE), /api/chat/{id}/* and /v1/agents/{id}/invoke[-stream]; 400 agent.not_active on POST /api/eval/runs, POST /api/eval/online, GET /api/eval/agents/{id}/log-streams, GET /api/experiments/readiness, POST /api/experiments; 400 canary.agent_not_active on POST /api/runtime-canaries; 404 registry.a2a_demo_agent; 404 registry.record_not_deployed on live-agent-card; 404 job.not_found on its deploy jobs |
| Cross-module mutations | 404 agent.not_found when accepting a recommendation of a visible run on a hidden agent, and on every experiment / canary action except verdict / cleanup; such a run's recommendation-inputs read as source: "manual" (the hidden agent's prompt / tools are not disclosed) |
| Agent-spec pins | create / convert: every pinned KB / memory must be visible (404 kb.not_found, 422 agent.memory_not_managed); redeploy / inbound-auth check only the pins added to the stored spec |
| Registry | its A2A record is dropped from GET /api/registry/records, /search, /discoverable; GET / PUT / DELETE / action / reimport on it → 404 aws.not_found |
| Observability | its traces / sessions are dropped from the lists; trace / session detail, transcript and evaluate → 404 observability.{trace,session}_not_found; /dashboard stays a workspace total |
| Memory console | its actors (<agent>__<human>) are dropped from /api/memory/actors and extraction jobs; sessions / events / namespaces / records / records/search keyed by such an actor or its namespace → 404 memory.actor_not_found; the shared default actor loses sessions the ledger attributes to it |
| Derived names | dropped from KB attached_agents (attached_agent_count keeps the true count) and the kb.has_attached_agents 409, memory-resource agents and the memory.in_use 409, Connections referenced_by (incl. the identity.connection_referenced 409), online matched_agent; discovery managed_agent_id / managed_agent_name and import already_managed read null with managed: true |
| Aggregates | GET /api/overview active_sessions and the A2A registry_assets.agents count only visible agents |
| Name uniqueness | workspace-wide; the 409 agent.name_exists detail.agent_id is null when the holder is hidden |
KB force-delete still strips the KB from every mounting agent.
/api/knowledge-bases/* backs the Knowledge Bases console (console 04) over
Bedrock managed knowledge bases — bedrock-agent for the control plane,
bedrock-agent-runtime for retrieval. Only type == "MANAGED" KBs are
addressable: a VECTOR KB in the same account answers kb.not_found. Only the
creator of a console-created KB is stored locally (see Resource creators below);
everything else is a live AWS call. See
architecture.md.
| Method | Path | Result |
|---|---|---|
GET |
/api/knowledge-bases?status= |
Every MANAGED KB with {kb_id, name, description, status, updated_at, data_source_count, attached_agents}; status is an optional exact-match filter applied after the read (e.g. ACTIVE) |
POST |
/api/knowledge-bases |
202 — CreateKnowledgeBase ({name, description?, source: {mode: "upload"|"existing", bucket?, prefix?}}) returns the detail while it is still CREATING, plus source_pending; the data source is created off-request by a backend thread once the KB is ACTIVE (1.5–3 min), so the client polls GET /{kb_id} |
GET |
/api/knowledge-bases/{kb_id} |
Detail: status, ARN, timestamps, failure_reasons, attached_agents, and each data source with bucket/prefix, status and its 10 most recent ingestion jobs |
PATCH |
/api/knowledge-bases/{kb_id} |
{description} (≤1000 chars) → UpdateKnowledgeBase with the name, role and configuration read back unchanged; answers the fresh detail |
DELETE |
/api/knowledge-bases/{kb_id}?force= |
Deletes data sources, the per-KB gateway Retrieve target and the per-KB inline S3 policy, then DeleteKnowledgeBase. 409 kb.has_attached_agents while agents mount it; force=true strips it from every mounted agent's spec (and re-syncs the harness agents' agentic targets) first |
POST |
/api/knowledge-bases/{kb_id}/files |
multipart/form-data, one or more parts named files (or file) → {keys} in the artifacts bucket under kb/{kb_id}/. Allowed while the data source does not exist yet; 409 kb.no_upload_target for a KB whose sources are all elsewhere |
POST |
/api/knowledge-bases/{kb_id}/data-sources |
201 — creates a MANAGED_KNOWLEDGE_BASE_CONNECTOR source from the same {mode, bucket?, prefix?} body and answers the fresh detail. Idempotent per S3 location: an existing connector on the same bucket/prefix is returned instead of a second one. This is also the manual repair for a KB left with no data source |
DELETE |
/api/knowledge-bases/{kb_id}/data-sources/{ds_id} |
DeleteDataSource → {deleted, ds_id} (deletion is asynchronous on the AWS side) |
POST |
/api/knowledge-bases/{kb_id}/data-sources/{ds_id}/sync |
StartIngestionJob → the job projection {job_id, status, started_at, updated_at, statistics, failure_reasons} |
GET |
/api/knowledge-bases/{kb_id}/data-sources/{ds_id}/ingestion-jobs |
The 50 most recent ingestion jobs, newest first, in the same projection |
GET |
/api/knowledge-bases/{kb_id}/data-sources/{ds_id}/documents?page_size=&token= |
One page of ListKnowledgeBaseDocuments (page_size 1–100, default 50) as {documents, next_token, page_size}; each document carries the KB-side status/status_reason/indexed_at plus S3-side size_bytes/uploaded_at joined by object key (absent when the backend cannot list the bucket) |
POST |
/api/knowledge-bases/{kb_id}/query |
Retrieval playground — {text, number_of_results?} (1–100, default 8) → Retrieve with a managedSearchConfiguration, answering {results} of {text, score, location_uri, metadata} |
POST |
/api/knowledge-bases/ensure-gateway |
Create-if-missing the shared launchpad-kb-gw MCP gateway and persist {id, arn, url} onto the workspace. Idempotent; the harness deploy path calls the same helper, so this is only needed to provision the gateway ahead of time |
Error codes: kb.not_found (404 — unknown id, or a KB that is not MANAGED),
kb.ds_not_found (404), kb.has_attached_agents (409, with the blocking names
in detail.agents), kb.delete_conflict (409 — the KB is still CREATING),
kb.no_upload_target (409), kb.no_files (400 — no upload part in the form),
kb.sync_not_ready (409 — StartIngestionJob hit ValidationException or
ConflictException: the data source is still provisioning, or a sync is already
running), kb.bucket_required / kb.invalid_bucket / kb.invalid_prefix /
kb.invalid_source (400 — source validation), kb.query_failed (502 —
retrieval failed on the KB side, e.g. the index is still building). Any other
AWS ClientError goes through the global mapping above (aws.validation,
aws.conflict, …). A workspace whose resource map has no kb_role_arn (create)
or no artifacts_bucket (uploads) has not been bootstrapped and raises a 500
naming the missing key.
/api/memory/* backs the read-only Memory console (console 05) over the shared
launchpad_memory singleton. Every console route is a read: there is no endpoint
that writes events, deletes records or triggers extraction. The one mutating
surface — the /api/memory/resources* routes below, which manage the memory
resources themselves — lives in a separate router (routers/memory_resources.py).
See architecture.md.
| Method | Path | Result |
|---|---|---|
GET |
/api/memory/overview |
Resource config, long-term strategies, bounded actor count, sibling memories |
GET |
/api/memory/actors |
Actors with the compound <agent_id>__<human> id decoded and the agent name resolved |
GET |
/api/memory/sessions?actor_id= |
Sessions for one actor, joined to the ChatSession ledger when the console wrote them |
GET |
/api/memory/events?actor_id=&session_id= |
Short-term events; each payload entry is kind conversational (role + full text), json (the JSON value serialized losslessly into text — null, false, 0 and "" included) or blob (byte count only); unknown kinds are omitted |
GET |
/api/memory/namespaces?actor_id= |
Strategy namespace templates with {actorId} substituted; trailing {sessionId} segments collapse into an actor-level prefix (prefix: true), a placeholder elsewhere yields resolvable: false |
GET |
/api/memory/records?actor_id=&strategy_id= or ?namespace= |
Long-term records for the resolved namespace |
POST |
/api/memory/records/search |
Semantic retrieval ({query, actor_id, strategy_id?, namespace?, top_k}) with relevance scores |
GET |
/api/memory/extraction-jobs |
Failed (retry-eligible) extraction jobs, filterable by actor_id/session_id/strategy_id/status — not surfaced in the console; AWS's status enum is FAILED only, so a healthy resource returns an empty list |
Memory resource management (?view=resources):
| Method | Path | Result |
|---|---|---|
GET |
/api/memory/resources |
Every memory in the workspace's account/region, default first, each with the live agents whose spec pins it and managed (see below) |
POST |
/api/memory/resources |
perm:memory.manage. CreateMemory ({name, description?, event_expiry_days?, strategies?, namespace_keys?}) → 201 with the detail projection in CREATING state; the new id is registered as managed |
GET |
/api/memory/resources/{memory_id} |
Detail projection: description, status, event expiry, execution role, strategies, namespace keys |
POST |
/api/memory/resources/{memory_id}/adopt |
admin. Registers an existing account memory as managed by the workspace (after GetMemory resolves it — unknown id → 404 aws.not_found); idempotent; replies with the detail projection |
PUT |
/api/memory/resources/{memory_id} |
perm:memory.manage. UpdateMemory limited to {description?, event_expiry_days?} — at least one required (422 otherwise), description 1–4096 chars (it can be replaced, not cleared), event_expiry_days 7–365 (422 outside). Sends exactly memoryId + the given fields and never namespaceKeys (the API replaces that set wholesale); the reply is the detail projection read back with GetMemory. Not blocked by referencing agents or the platform default; unknown id → 404 aws.not_found |
DELETE |
/api/memory/resources/{memory_id} |
perm:memory.manage. DeleteMemory (irreversible); 409 memory.platform_protected for the workspace default, 409 memory.in_use (with the agents) while a live agent's spec pins it; the managed registration is dropped |
Ownership. The account can hold memories the platform never created, so a
memory is managed only when it is the workspace's bootstrap memory or a
managed_memories ledger row names it (written by POST above or by an
administrator's adopt — never from a client payload). Every per-id route answers
404 memory.not_managed for any other id, before any AWS call; the list still
shows such memories with managed: false. A spec's memory.memory_id must be
managed and ACTIVE: POST /api/agents, re-publish and convert refuse it with
422 agent.memory_not_managed / 409 agent.memory_not_active, and the deploy job
re-checks before any stage. The Chat memory rail answers
409 agent.memory_not_managed for a legacy spec pinning an unmanaged id.
Every list route accepts and returns next_token (AWS pages at 100 items) and
accepts max_results (clamped to 100) — nothing is capped silently. Namespace
resolution order on /records and /records/search: an explicit namespace
wins, otherwise it derives from actor_id (+ optional strategy_id).
Error codes: memory.not_configured (409, bootstrap has not run — except
/overview, which instead returns {"configured": false, …} so the page can
render a setup state), memory.namespace_required (400, no namespace could be
derived), memory.unavailable (502, the underlying AWS call failed).
/api/chat/* backs the Chat playground over the same invoke chain as /v1
(app.services.invoke). Sessions are AgentCore Runtime sessions: the id the
console sends as runtimeSessionId is the one the ledger tracks.
| Method | Path | Result |
|---|---|---|
POST |
/api/chat/{agent_id} |
One turn as SSE (meta → delta/tool/auth_required/policy_denied/error → done); {prompt?, attachments?, session_id?}, a missing id starts a new session; message text or at least one attachment is required. as_user?: bool (JWT-inbound agents only): true sends the signed-in user's Cognito JWT, false the workspace M2M token, omitted = the user JWT when a pool sign-in exists, else M2M; 409 chat.as_user_unavailable when true without a pool sign-in. meta.inbound = {mode: jwt, caller: user_jwt|m2m}; the Memory actor is scoped_actor in both cases. auth_required {provider, tool, scopes[], url, agent_id} is an as_user (3LO) consent ask from a tool: url is the single-use authorization URL (the console links it only when it is https:), the sessionUri stays server-side, and the answer keeps streaming around it. History keeps the ask as a role: "auth" row (text = the Connection, name = the tool) without the URL, so a restored ask can only be retried; poll GET /api/identity/grants/{connection}/status to see when consent completes. policy_denied (Harness; payload under Streaming invoke) is kept as a role: "policy" row (text = the reason, name = the tool) and restored as the V2 Chat policy-deny card, which then links to the Governance landing page rather than a specific gateway |
GET |
/api/chat/{agent_id}/sessions |
Replayable sessions for the agent: {session_id, actor_id, turns, last_at, ended_at, preview} — ended_at is set once the console explicitly ended the runtime session, null while it is live or merely idle |
GET |
/api/chat/{agent_id}/history?session_id= |
The rendered thread items of one session, in replay order |
POST |
/api/chat/{agent_id}/sessions/{session_id}/stop |
END SESSION — data-plane StopRuntimeSession(agentRuntimeArn, runtimeSessionId) → {session_id, ended: true, already_ended, ended_at}. already_ended: true when AWS answered ResourceNotFoundException (the session had already ended or idle-expired) — a success, not an error. The ledger row is kept (history stays replayable) and stamped ended_at; a later turn posted under the same id starts a fresh runtime session and clears it. Only runtime-backed agents qualify (zip_runtime, studio, container, discovered runtimes); a managed Harness — deployed or imported — has no session-stop operation and answers 409 chat.session_stop_unsupported with detail.reason_code (harness). A session of another agent or workspace is 404 chat.session_not_found. A RetryableConflictException that outlives botocore's retries is 409 aws.conflict |
Ending is explicit: NEW SESSION in the console only forgets the id locally, so the runtime session it leaves behind idles out on its own. END SESSION is what to press after a re-publish — AgentCore pins a live session to the version that first served it, so validation of the new version needs a fresh session.
All invoke entrances accept optional
attachments: [{name, media_type, data}], where data is standard base64.
GET /api/agents and /v1/agents expose each agent's attachment_capability,
including images, text, pdf (native/text/unsupported), accepted
extensions, limits and a reason code when native inputs are unavailable.
Limits are five files, 3 MiB per file and 10 MiB total decoded bytes; the request body is capped at 15 MiB. PNG/JPEG/WebP/GIF, PDF and UTF-8 text/code formats are validated by content before invocation. PDFs are limited to 50 pages. Combined prompt and extracted text may not exceed 100,000 characters.
Harness accepts text files and explicitly labeled PDF text extraction. Images and nonblank scanned/graphic-only PDF pages require a native-capable agent. Generated Strands/Claude/Studio/converted HTTP runtimes need a compatible published artifact and a fresh session; old entrypoints cannot silently discard files. Native attachments are unavailable during an active canary.
The SSE meta.attachments and each history message's attachments contain
{name, media_type, size, delivery} metadata (native, text, or pdf_text).
Original file bytes are transient and are not stored in chat history. File
validation returns localized chat.attachment_* errors before SSE starts;
runtime acknowledgement/model failures use the existing stream error envelope.
/api/eval/datasets holds the local scenario datasets (SQLite, the editable source
of truth) and their one AWS Dataset each. AWS datasets have a DRAFT plus
immutable numbered versions: SYNC TO AWS creates the dataset once and afterwards
replaces the draft's examples in place; PUBLISH VERSION snapshots the draft.
| Method | Path | Notes |
|---|---|---|
GET |
/api/eval/datasets |
{datasets[]} — local rows with items, kind, has_ground_truth and the cloud blob |
POST |
/api/eval/datasets |
Create from items (devguide scenarios, simulated personas or legacy prompts; kind is inferred) → 201 |
PUT · DELETE |
/api/eval/datasets/{dataset_id} |
Edit (kind is immutable → 400 dataset.kind_immutable) / delete the local row; a synced AWS copy stays |
POST |
/api/eval/datasets/{dataset_id}/sync-to-aws |
Without a live cloud copy: CreateDataset (inline examples) polled to ACTIVE. With one: edit its draft in place — ListDatasetExamples → DeleteDatasetExamples (skipped when the draft is empty) → AddDatasetExamples with the normalized scenarios, each polled through UPDATING to ACTIVE; the dataset id and published versions survive and the draft reads MODIFIED. A copy AWS no longer knows (ResourceNotFoundException on GetDataset) or marked deleted is re-created. Returns the row; CREATE_FAILED / UPDATE_FAILED / timeout → 502 dataset.sync_failed with the AWS failureReason, also recorded on the blob |
POST |
/api/eval/datasets/{dataset_id}/publish-version |
CreateDatasetVersion on the row's cloud copy, polled through UPDATING to ACTIVE → the row with the new version first in cloud.versions and cloud.draft_status == "UNMODIFIED". No live copy → 409 dataset.not_synced; UPDATE_FAILED / timeout → 502 dataset.publish_failed (reason recorded on the blob, versions kept) |
GET |
/api/eval/datasets/cloud |
Every AWS dataset in the workspace region: {datasets[{datasetId, name, status, schemaType, exampleCount, draftStatus, updatedAt}]} |
GET |
/api/eval/datasets/cloud/{cloud_id} |
Draft detail: {datasetId, name, status, schemaType, exampleCount, draft_status, failure_reason, versions[{version, example_count, created_at}], runnable, has_ground_truth} — versions newest first |
POST |
/api/eval/datasets/cloud/{cloud_id}/publish-version |
PUBLISH VERSION for a cloud-only dataset → the refreshed detail above; failures as for the local route |
DELETE |
/api/eval/datasets/cloud/{cloud_id} |
DeleteDataset — the draft and every version; local rows pointing at it are marked cloud.status = "deleted" and re-create on the next sync |
DELETE |
/api/eval/datasets/cloud/{cloud_id}/versions/{version} |
DeleteDataset with datasetVersion — one published version; the draft and the other versions stay, cached lists are refreshed |
The cloud blob on a local row: {dataset_id, arn, status, synced_at, failure_reason, draft_status (MODIFIED|UNMODIFIED), example_count, versions[{version, example_count, created_at}]}. It caches display state only — AWS is the source of truth and every
mutation re-reads GetDataset / ListDatasetVersions.
Console V2's 数据中心 turns observed sessions (Agent trajectories) into local
evaluation datasets. One extractor (app/evaluation/pipelines.py) serves both
routes below: it reads the session transcript through the observability service
(same private-session visibility rule as /api/observability/sessions), pairs each
user input with the following agent reply, and writes a predefined scenario per
session (scenario_id = trace-<session>, turns[{input, expected_response}],
metadata.source = "trace") — or a {prompt, expected} pair from the first
exchange when the target is a legacy dataset. Simulated-persona datasets are
refused (400 dataset.kind_unsupported). Merging skips a scenario id already
present, optionally an identical first input (dedupe), and anything past the
200-item cap; nothing here calls a model.
A pipeline source can instead read CloudWatch log groups (source.type = "logs",
app/evaluation/pipeline_logs.py): one Logs Insights query takes the window's newest
records (at most 10,000; keyword narrows them, case-insensitive), and a format rule
embedded in the pipeline turns each record into {role, text} turns grouped by
session — genai (OTel GenAI content records such as otel-rt-logs, filtered
server-side and read like the session transcript), message (one record = one
message: session_field + role_field + text_field, roles mapped through
user_roles / assistant_roles) or exchange (one record = one question:
input_field + optional output_field; no session_field makes each record its own
scenario under a stable hashed id). Fields are JSON dot paths only (no regex);
@logStream as session_field makes a stream one session, and a text prefix before
a record's JSON is skipped. The turns then go through the trace extractor:
scenario_id = log-<session>, metadata{source: "logs", session_id, log_group}.
processing.keep_replies: false (either source) stores inputs only.
| Method | Path | Behavior |
|---|---|---|
POST |
/api/eval/datasets/from-sessions |
{session_ids[1..50], range, dataset_id XOR name, description?, first_turn_only?, dedupe?} → 201 {dataset, added, skipped[{session_id, reason}]}. Reasons: not_found, no_transcript, no_exchange, and (with an empty session_id) duplicate / dataset_full. A new dataset with nothing usable → 422 dataset.nothing_extracted with detail.skipped |
GET · POST |
/api/eval/pipelines |
List / create a saved processing task: {name, description, source{type: traces|logs, range, max_sessions ≤ 50, agent, status: all|ok|error (traces), log_groups[1..10], keyword?, format{preset: genai|message|exchange, session_field, role_field, text_field, user_roles, assistant_roles, input_field, output_field} (logs)}, processing{first_turn_only, dedupe, min_input_chars, keep_replies}, output{dataset_id XOR dataset_name}} (an unknown or simulated output dataset → 404 / 400; a source without type is traces) |
POST |
/api/eval/pipelines/preview-logs |
{source} (a logs source) → what it reads and extracts now, through the converter a run uses: {events, truncated, parsed, failed{not_json|not_genai|no_session|no_text|other_role: n}, sessions_found, sessions[≤8 {session_id, stream, log_group, last, records, turns}], samples[≤5 raw {stream, timestamp, message}], paths[≤60 dotted field paths seen in the records]}; a traces source → 422 pipeline.not_logs |
GET · PUT · DELETE |
/api/eval/pipelines/{pipeline_id} |
Read / replace (409 pipeline.running while running) / delete — the output dataset is kept |
POST |
/api/eval/pipelines/{pipeline_id}/run |
202 with the row as running (409 pipeline.running if it already is); the bounded run proceeds in the background: list the window's sessions, filter by agent/status, read at most max_sessions newest, extract, merge (a logs source: query, convert, take the max_sessions newest sessions). The outcome lands on the row as last_run{at, scanned, matched, added, skipped, dataset_id, error} (plus log{events, parsed, failed, truncated} for a logs source) with status succeeded / failed (a run interrupted by a server restart reads back as failed); a dataset_name output is created on the first run and the pipeline then targets its id |
GET |
/api/eval/log-services?hours=1..336&log_group=… |
Service names seen in the spans of aws/spans (plus any log_group given — spans sent to an agent's own group), one Logs Insights query → {services[{service_name, spans, sessions, last_seen, log_group_names, agent}], log_groups, hours}. log_group_names is the suggested batch input: aws/spans + the content log group(s) the spans' resource names (aws.log.group.names); agent is the platform agent that owns the service, or null; scopes are its spans' instrumentation scopes and evaluable says whether AgentCore Evaluation reads any of them as agent spans (the supported-frameworks scopes, or the generic opentelemetry.instrumentation.* / openinference.instrumentation.* prefixes minus transport instrumentation such as botocore or starlette; null = unknown) — a service with only custom scopes fails every session with "No evaluable agent spans found" |
GET |
/api/eval/log-groups?q= |
Log groups whose name contains q (case-insensitive logGroupNamePattern), at most 150 → {log_groups[{name, created_at, retention_days, stored_bytes}], truncated} |
GET |
/api/eval/log-sessions?service_name=&log_group=…&hours=&q= |
The 日志 source of a log_source task: sessions of service_name in the given log groups (1–10), found from its spans (the records that always carry both service.name and session.id), newest first, at most 500 — in the /agents/{id}/log-streams row shape (stream = the log group · stream of the session's latest record, plus traces). q keeps sessions with any record containing it (case-insensitive regex match over spans and content logs, with the hit count and an excerpt). Private assistant sessions of other principals are omitted |
GET |
/api/eval/agents/{agent_id}/log-streams?hours=1..336&q= |
The evaluation-task wizard's 日志 source (app/evaluation/log_streams.py): the streams of the agent's runtime log group with an event in the window, newest first → {log_group, streams[{stream, session_id, kind, first_event, last_event, match, matches, snippet}], truncated, hours, q}. kind: session (a code runtime's [runtime-logs-<sessionId>] stream), otel_session (one session's slice of otel-rt-logs — a Harness runtime names its streams per microVM, so its sessions exist only there) or shared (no single session; not selectable). q keeps rows whose stream name contains it (case-insensitive) or whose events contain it as a literal term (FilterLogEvents, case-sensitive; an otel-rt-logs hit is credited to the session its event names), with match name / content, the hit count and an excerpt. Both scans are capped (500 streams, 10 filter pages) — truncated says a cap hid sessions. Another principal's private assistant session is omitted. The picked session_ids start an ordinary POST /api/eval/runs with session_ids + session_source: "logs" |
All routes are MEMBER and workspace-scoped (eval_pipelines.workspace_id).
/api/eval/evaluators is the custom-evaluator CRUD behind the ?view=evaluators
sub-page. AWS is the source of truth (no ledger row); built-in and third-party
evaluators are read-only. A custom evaluator has exactly one of three
definitions, chosen by which body field is present — instructions
(LLM-as-a-judge, llmAsAJudge), base_evaluator_id (derived, derived) or
lambda_arn (code-based, codeBased.lambdaConfig); two or none → 400
evaluator.definition_ambiguous.
| Method | Path | Notes |
|---|---|---|
GET |
/api/eval/evaluators |
{evaluators[], builtin_count} — local builtin catalog first (source: builtin, trajectory matchers flagged requires_ground_truth), then the account's ListEvaluators rows (source: third_party | custom, evaluator_type, provider, status). Custom rows carry definition: judge | derived | code (read off evaluatorType — the list has no config). A failed account listing still answers 200 with the builtins only plus custom_unavailable: {code, message} (aws.*, workspace.assume_role_failed or evaluators.list_failed; generic message, never the AWS text); the key is absent on success |
POST |
/api/eval/evaluators |
Create → 201 {evaluator_id, arn}. Common: name (^[a-zA-Z][a-zA-Z0-9_]{0,47}$), description. Judge: instructions (10–4000 chars, ≥1 {placeholder} else 422 evaluator.missing_placeholder), rating_scale[≥2] (default pass/fail), model_id, level (TOOL_CALL | TRACE | SESSION, default TRACE). Derived: base_evaluator_id (Builtin.* | ThirdParty.*; unknown → 400 evaluator.base_not_found), model_id; the level is the base's. Code-based: lambda_arn (arn:aws[-partition]:lambda:<region>:<account>:function:<name>[:qualifier]), lambda_timeout_s 1–300 (default 60), level; the Lambda must be in the workspace Region, else 422 evaluator.lambda_region_mismatch (detail: {lambda_region, workspace_region}). rating_scale with a derived or code-based body → 400 evaluator.rating_scale_not_allowed. CreateEvaluator requires level for every definition |
GET |
/api/eval/evaluators/{evaluator_id} |
{id, name, level, description, definition, instructions, rating_scale, model_id, base_evaluator_id, lambda_arn, lambda_timeout_s, evaluator_type, provider, status} — the other definitions' fields are empty/null (a code-based evaluator has instructions: "", rating_scale: [], model_id: null) |
PUT |
/api/eval/evaluators/{evaluator_id} |
Full-config replace (UpdateEvaluator takes the complete config, so every field is sent back) with the same bodies as create minus name → the refreshed detail. The payload must be of the evaluator's current definition: a judge/derived payload against a code-based evaluator, or a code payload against a judge/derived one → 400 evaluator.definition_mismatch (detail: {current, payload}) — the evaluator is never converted. Managed ids → 400 evaluator.builtin_immutable |
DELETE |
/api/eval/evaluators/{evaluator_id} |
DeleteEvaluator → {deleted: true}; managed ids → 400 evaluator.builtin_immutable. Evaluators referenced by an ENABLED online config are locked by AWS |
Code-based (Lambda) contract. The function is invoked by the service with
{schemaVersion, evaluatorId, evaluatorName, evaluationLevel, evaluationInput.sessionSpans, evaluationReferenceInputs, evaluationTarget} and returns {label, value?, explanation?}
or {errorCode, errorMessage}; the invocation is capped at the configured timeout
(≤ 300 s) and 6 MB of payload. The console manages no IAM for it: the
evaluation execution role the platform passes as evaluationExecutionRoleArn on
batch and online runs needs lambda:InvokeFunction + lambda:GetFunction on the
function, and the function's resource policy must allow the
bedrock-agentcore.amazonaws.com principal (scope it with aws:SourceAccount /
aws:SourceArn). Neither is checked at create time — a run against a function the
role cannot invoke fails per session, like any evaluator error.
Live callbacks can omit both documented evaluator identity fields. Assistant-created
code evaluators therefore use one rule set per published Lambda package; they never
choose an arbitrary rule from a shared package. Selecting a known managed legacy
package with zero or multiple rule sets returns 422 run.evaluator_package_ambiguous
before a new run, invocation or batch is created. The response identifies the
evaluator, owning operation and packaged rule-set count.
/api/eval/runs drives batch evaluations / insights analyses through the bounded
run queue (eval_max_concurrent_runs, capped at the 5 active-batch-evaluations
account quota). Run status: queued → invoking → waiting → evaluating → completed | failed | stopped. Every row carries stop_requested (an operator stop is pending
on a run whose batch is still STOPPING).
POST /api/eval/runs also takes an optional operator-facing name (1–64) and
description (≤ 1000) — console V2 lists runs as named evaluation tasks — and every
run row carries them (null on unnamed runs) plus updated_at.
Before creating a run or invoking an agent, known managed code rules are compared
with the target Agent's actual tools, knowledge_bases and skills. An
unqualified zero-call rule or exact empty tool sequence against mounted resources
returns 422 run.evaluator_capability_conflict, with evaluator/rule identifiers and
an explanation in detail.evaluators. A named forbidden write tool remains valid.
External code evaluators whose rule definitions are unknown are not classified by
their display names.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/eval/runs?limit&offset&mode&agent_id |
Newest-first page {runs, total, limit, offset} |
GET |
/api/eval/runs/{run_id} |
One run (scores / insight trees / batch_eval_id / budget_stops — dataset scenarios that ended on the agent's own budget and are scored as they stand, [{scenario_id, session_id, code, stop_reason}] / scenario_failures — simulated persona scenarios skipped after their retries while the rest of the run went on, [{scenario_id, retries, code, error}] (the run fails only when every scenario failed) / session_failures — for a batch that ended COMPLETED_WITH_ERRORS, {total, failed, kind, sessions: [{session_id, index, kind, error_type, message}]} where kind is telemetry_incomplete (missing/incomplete-span errors only: the traces never fully reached CloudWatch, re-evaluating cannot recover them), evaluator_error, mixed or unknown, index the 1-based scenario position; null on a clean run or a row finished before it existed, backfilled the first time its results are read / error / stop_requested). |
GET |
/api/eval/runs/{run_id}/results |
Per-session judgements of a terminal evaluators run — {available, sessions: [{session_id, results: [{evaluator_id, level, score, label, explanation, error_type, error_message}]}], count, truncated}. The row only stores per-evaluator averages; the judge's explanation of every score lives in the batch's own results log stream (GetBatchEvaluation.outputConfig.cloudWatchConfig, the same gen_ai.evaluation.result records the online-evaluation views read), which this route reads on demand and never persists. Sessions follow the run's session_ids order. available=false + reason (insights_run | no_batch | run_active | stream_missing | unreadable + detail) instead of an error when there is nothing to read. Unknown → 404 run.not_found |
POST |
/api/eval/runs |
Start a run (exactly one scope: dataset_id | cloud_dataset_id | session_ids | lookback_hours) → 201. session_source: "logs" (display only, session_ids scope only — else 422 run.session_source_scope) records the scope as dataset_name logs:<n>, like window:<N>h. Target: exactly one of agent_id or log_source {service_name, log_group_names[1..10]} (else 422 run.target_required). A log_source run evaluates telemetry already in CloudWatch with no platform agent behind it (e.g. an agent not hosted on AgentCore Runtime): the batch's cloudWatchLogs source is serviceNames: [service_name] + logGroupNames as given, only passive scopes apply (session_ids / lookback_hours; a dataset scope → 422 run.log_source_scope), and every log group must exist (422 run.log_group_missing, detail.missing). The row echoes log_source with agent_id: "" and agent_name = the service name. A cloud_dataset_id scope may add dataset_version (a published version number such as "2", never DRAFT; omitted = the draft): the version must exist in ListDatasetVersions (else 422 run.dataset_version_unknown, no run row) and GetDataset / ListDatasetExamples read that snapshot. dataset_version with any other scope → 422 run.dataset_version_scope. Every run row echoes dataset_version (null for draft, local, session and window runs). |
POST |
/api/eval/runs/{run_id}/stop |
Stop an active run → 202 with the run. A run whose batch exists on AWS (batch_eval_id set) is stopped with StopBatchEvaluation: the batch goes STOPPING → STOPPED, the sessions already judged keep their results, and the poller records the run as stopped with those partial scores / insight trees and error = "stopped by operator". A run still queued is cancelled locally (the worker skips it, AWS is never called) and returns stopped at once. A run replaying its dataset or waiting for telemetry (no batch yet) stops between prompts and never calls StartBatchEvaluation. Terminal runs (completed / failed / stopped) → 409 run.not_active; unknown → 404 run.not_found. DeleteBatchEvaluation is deliberately not exposed — the ledger keeps the partial results AWS would drop |
POST |
/api/eval/runs/{run_id}/recheck |
eval.run — Re-read a failed run's batch from AWS → 202 with the run. For a failed run that started a batch (batch_eval_id set) — or a partially failed completed evaluator run with no session_failures yet, which re-finishing backfills: one GetBatchEvaluation; a terminal batch settles the row exactly as the poller would have (e.g. completed with scores, or failed with the batch's own reason), a batch still running puts the row back to evaluating with a fresh poller. Reads only — nothing is re-run or billed. Other runs → 409 run.not_recheckable; unknown → 404 run.not_found. Every batch wait is bounded by eval_batch_wait_s (default 7200 s, 30 s polls); a run that outlives it fails with "batch evaluation still after the N-minute wait" and can be re-checked from the V2 task detail |
DELETE |
/api/eval/runs/{run_id} |
eval.run — Remove a run that produced no result (failed / stopped) from the ledger → {deleted, run_id, status, aws_batch_left_in_place}. Only the row goes: a batch evaluation that reached AWS is left as it is (AWS stays the source of truth; the row was the console's pointer and the answer names it). completed runs are the evaluation history and active runs must be stopped first → 409 run.not_deletable; unknown → 404 run.not_found. The Evaluation runs table and the assistant's NEXT STEPS history show ✕ on such rows |
GET |
/api/eval/runs/{run_id}/recommendation-inputs |
What a recommendation from this run would revise → {source, system_prompt, tools: [{name, description, origin}], notes, evaluators, default_evaluator, eligible, reason_code}. source: "harness" = a Managed Harness read live (GetHarness: the system prompt, each inline_function description, and every attached agentcore_gateway's target tool schemas named <target>___<tool>, all narrowed by the Harness allowedTools); "spec" = the Launchpad agent spec (prompt + discoverable tools); "manual" = nothing readable (BYOC without a prompt, CloudWatch-sourced runs, deleted agents) — the console then requires both inputs. notes names what could not be read (remote_mcp_runtime_only, gateway_unreadable, and harness_unreadable when the run outlived its Harness or GetHarness is denied — the inputs then fall back to the spec / manual instead of failing). evaluators = every evaluator the job can optimize toward, as `[{id, name, level, group: run |
GET |
/api/eval/runs/{run_id}/recommendations |
Recommendations started from this run, newest first → {recommendations: [{id, kind, recommendation_id, status, input_source, system_prompt, evaluator, tools, skipped_tools, result, error, …}]}. Each non-terminal row is refreshed with one GetRecommendation on read (no background poller); a job that ends without text reads as FAILED with AWS's own error. A tool job refused because some tools never appear in the traces is restarted once without them (skipped_tools) |
POST |
/api/eval/runs/{run_id}/recommendations |
eval.run — Start recommendations {kinds: ["system_prompt" | "tool_descriptions"], input_source, system_prompt?, evaluator?, tools?: [{name, description}]} → 201 {recommendations}. One StartRecommendation per kind, scoped to the run's sessions: the system-prompt job pins agentTraces.batchEvaluation to the run's batch; the tool-description job refuses that source (live ValidationException), so it gets the same sessions' spans inline (sessionSpans, read with the on-demand evaluation query, ≤ 20 000 spans) and tools whose name appears nowhere in them are skipped up front (skipped_tools). Only a completed run with a batch → else 409 recommendation.run_not_completed / run_no_batch; a tool job on a run without session ids → 409 run_no_sessions, with expired telemetry → 409 no_spans, with no traced tool → 422 tools_not_traced; empty prompt / no tools / a tool without description → 422. Every refusal happens before the first Start, so a request never leaves one kind running and the other unstarted; a custom judge with a categorical rating scale → 422 recommendation.evaluator_categorical. Results are shown for review/copy; only the accept route below applies one |
PUT |
/api/eval/runs/{run_id}/recommendations/{rec_id}/edit |
eval.run — save a manual revision of a COMPLETED, not yet accepted system-prompt recommendation. Body {system_prompt}; null, blank or the generated text drops the revision. Stored as edit {prompt, by, at} apart from result (which GetRecommendation refreshes rewrite), returned by the list route; the console diff, COPY and ACCEPT then use it. Ledger-only — nothing is published. Accepted → 409 recommendation.already_accepted; tool descriptions → 400 recommendation.edit_kind; not completed → 409 recommendation.not_completed |
POST |
/api/eval/runs/{run_id}/recommendations/{rec_id}/accept |
agents.deploy — Accept a COMPLETED system-prompt recommendation into a new Harness version → 202 {agent, job_id, deployment_id, recommendation}. Body {system_prompt?} — the reviewed text to publish; omitted = the saved edit, else the generated recommendation (accepted.edited records any difference from the generated text). Re-publishes the run's platform-deployed Harness through the same guards and update-mode deploy job as POST /api/agents/{id}/redeploy (UpdateHarness → a new immutable version; DEFAULT follows it). The platform-appended ## Knowledge bases section is stripped from the recommended text first — the recommendation revised the live prompt, which already carries it. Records accepted {by, at, agent_id, previous_version, job_id, deployment_id} on the row (then exposed by the list route); accepted once → 409 recommendation.already_accepted; a tool-description recommendation → 400 recommendation.accept_kind; not completed / no text → 409 recommendation.not_completed; a non-Harness agent → 400 recommendation.accept_not_harness |
GET |
/api/eval/queue |
{running, queued, locked, max_concurrency} — cancelled runs leave the queue immediately, so the count covers active runs only |
/api/eval/online/* manages AgentCore online evaluation configs — continuous,
sampled scoring of live sessions. AWS is the source of truth; the ledger keeps
identifiers only. Every config in the workspace account is listed and classified
by owner: agent (created here for an agent), experiment (exp_*/can_*
arms owned by experiments — read-only), external (anything else).
| Method | Path | Result |
|---|---|---|
GET |
/api/eval/online |
{configs, total} — all configs, newest first, with owner, both statuses, failure_reason, evaluators, sampling, timeout, matched_agent (external rows whose log group matches a workspace agent), duplicate_enabled (two ENABLED agent configs on one agent), results_log_group |
POST |
/api/eval/online |
Create for an active agent: {agent_id, mode: scores|insights (scores), evaluators[1..10] (scores mode), insights[1..3] ⊆ Builtin.Insight.FailureAnalysis|UserIntent|ExecutionSummary + clustering_frequencies[0..3] ⊆ DAILY|WEEKLY|MONTHLY (insights mode), sampling_percentage 0.01–100 (omit → 10 scores / 100 insights), session_timeout_minutes 1–1440 (15), filters[0..5], description?, enable_on_create (true)} → 201 row (status starts CREATING). Mixing kinds → 422 online_eval.mode_conflict; rows carry mode (derived: insights non-empty) |
GET |
/api/eval/online/{config_id} |
Full detail incl. filters, data_source, execution_role_arn |
PATCH |
/api/eval/online/{config_id} |
owner=agent only: any of description, sampling_percentage, session_timeout_minutes, filters plus the mode's own analysis field — evaluators (scores) or insights / clustering_frequencies (insights; complete lists, [] frequencies clears clustering); the other kind → 422 online_eval.mode_conflict, mode is immutable. The backend re-sends the complete rule (AWS replaces it as a unit) |
POST |
/api/eval/online/{config_id}/pause · /resume |
Flip executionStatus (agent + external) |
DELETE |
/api/eval/online/{config_id} |
Delete on AWS + drop the ledger row (agent + external); the results log group is left in place and named in the response |
GET |
/api/eval/online/{config_id}/results?range=1h|6h|24h|7d |
Logs Insights over the results log group: evaluators[{evaluator_id, level, mean, count, sessions, labels}], series{evaluator: [{bucket, mean, count}]}, recent[≤50] with judge explanation, errors{count, first_message}; empty collections while nothing has been evaluated yet |
GET |
/api/eval/online/{config_id}/reports |
Insights reports = batch evaluations sourced from the config: {config_id, mode, reports[{batch_id, name, status, run_status, created_at, updated_at, insights, sessions{completed, failed, in_progress, total}, origin: aws_scheduled|console, run_id, error}], aws_unavailable} newest first (aws_unavailable: true when ListBatchEvaluations failed — console rows only) — console runs from the ledger (EvalRun.dataset_name == "online:<config_id>") merged with AWS-scheduled batches attributed by GetBatchEvaluation.dataSourceConfig.onlineEvaluationConfigSource.onlineEvaluationConfigArn (only source-less summaries are candidates; one Get each, cached per batch id). Any owner may read |
POST |
/api/eval/online/{config_id}/reports |
RUN REPORT NOW {range: 1h|6h|24h|7d (24h)} → 202 {run_id, status, queue_position}: agent-owned insights configs only (403 / 422 otherwise); an EvalRun(mode=insights, dataset_name="online:<config_id>") through the bounded run queue whose batch uses onlineEvaluationConfigSource — it covers only the sessions the config sampled in the window and inherits the config's insights (AWS rejects explicit evaluators/insights on that source) |
GET |
/api/eval/online/{config_id}/reports/{batch_id} |
{batch_id, name, status, created_at, updated_at, time_range, sessions, insights{failures, userIntents, executionSummaries}, error_details} (parse_insights trees, same as a Runs-page insights run); 404 online_eval.report_not_found when the batch is not sourced from this config |
Filter shape: {key: "[a-zA-Z0-9._-]+", operator: Equals|NotEquals|GreaterThan|LessThan| GreaterThanOrEqual|LessThanOrEqual|Contains|NotContains, value: {stringValue|doubleValue|booleanValue}}
(exactly one typed value).
Error codes: online_eval.no_telemetry (400, the agent has no telemetry log group
yet — run one session first), online_eval.evaluator_unsupported (400, trajectory
matcher / unknown built-in / custom judge that needs ground truth),
online_eval.read_only (403, action not allowed for that owner),
online_eval.not_found (404), online_eval.conflict (409, name collision after one
retry), online_eval.workspace_not_bootstrapped (400), online_eval.invalid_filter
/ online_eval.bad_range (422).
Results appear only after a session is idle for session_timeout_minutes; custom
evaluators referenced by an ENABLED config are locked by AWS (no edit/delete).
Online scores also surface where sessions are looked at:
| Method | Path | Result |
|---|---|---|
GET |
/api/observability/sessions/{session_id} |
The session detail carries online_scores: {configs[{config_id, config_name, owner, agent{id,name}?, records[{time, evaluator_id, level, score, label, explanation, trace_id}]}], total, unavailable, configs_exist} — every config's result records for that session (agent-owned blocks first), read with one prefix SOURCE logGroups(namePrefix: ['/aws/bedrock-agentcore/evaluations/results/']) query. Fail-soft: a results-query failure sets unavailable: true and never removes traces or transcript; configs_exist is whether the workspace has an agent-owned config (the UI hides the block when neither results nor configs exist) |
GET |
/api/observability/sessions/{session_id}/transcript |
Conversation only — {session_id, transcript} with the same transcript shape as the session detail, and no Logs Insights query (the V2 evaluation result drawer's read). Optional agent_id attributes a session no ledger row claims (an id from another workspace is ignored). Eval-run sessions with no memory events rebuild turns from the runtime's otel-rt-logs content records — for a harness, from its backing runtime's log group (harness_<name>-<id>-DEFAULT), which is the only copy when the harness has memory disabled |
GET |
/api/overview/online-quality |
ONLINE QUALITY · 24h tile: {range: "24h", mean, scores, sessions, agents, configs, evaluators[{evaluator_id, mean, count, polarity}], cached} — count-weighted mean over every (evaluator, agent-owned config) pair with lower-is-better evaluators inverted (1 − mean), so the tile always reads higher-is-better; evaluators[].mean stays raw; configs counts the workspace's agent-owned configs (ledger) and agents the agents that scored, so "configured, nothing judged yet" is distinguishable from "no config". 120 s per-workspace cache with single-flight, force=true bypasses; a workspace without agent-owned configs answers the empty payload without any AWS call |
The Observability session detail (/observability?session=<id>) can score a
session right now with the AgentCore data-plane Evaluate API. This is the
third scoring mode next to batch runs (asynchronous, persisted, scoped by
dataset / session ids / window) and online evaluation (sampled, continuous):
| Mode | Call | Latency | Where results live |
|---|---|---|---|
| Batch run | StartBatchEvaluation (POST /api/eval/runs) |
minutes, polled | AWS results log group + ledger EvalRun |
| Online | CreateOnlineEvaluationConfig (POST /api/eval/online) |
continuous, ≈10 min judge lag | AWS results log groups, read back per session |
| On demand | Evaluate (POST /api/observability/sessions/{id}/evaluate) |
synchronous, one judge inference per evaluator | response body only — nothing is persisted |
| Method | Path | Body / Result |
|---|---|---|
POST |
/api/observability/sessions/{session_id}/evaluate |
Body {evaluator_ids: string[] (1..5, Builtin./ThirdParty. / custom id), range?: "1h"|"6h"|"24h"|"7d" (default 24h)}. Fetches the session's raw span records with one Logs Insights query over both telemetry layouts (filter ispresent(scope.name) and attributes.session.id = "<id>" | fields @message | sort @timestamp asc | limit 2000, non-JSON rows skipped), then calls evaluate(evaluatorId, evaluationInput={sessionSpans}) once per evaluator, sequentially (≤10 results per call). Returns {session_id, range, span_count, results[{evaluator_id, evaluator_name, evaluator_arn, value, label, explanation, span_context{sessionId,traceId?,spanId?}, token_usage{input,output,total}, error_code, error_message}]}. A result carrying error_code is a per-evaluator partial failure (row returned, request still 200). Session-level only: no evaluationTarget, no ground-truth reference inputs. |
Errors: observability.session_spans_missing (409 — no span records for the
session in the range yet; detail.hint explains spans land a couple of minutes
after the invoke), observability.too_many_evaluators (422), the standard
validation.invalid_request (422 — >5 ids, empty list, bad range or id shape),
aws.validation (400 — the AWS ValidationException for unsupported spans),
observability.query_failed (502 — Logs Insights failure/timeout). Results are
never written to the ledger; re-run any time (each run costs one judge
inference per evaluator).
The Skill Lab evaluation detail (/skill-lab?view=eval&job=<id>) reads one
finished job's judged rows from the CLI's out/results.json; the file, not the
ledger, is authoritative and is re-read per request. The route is unchanged;
its response gained a validated token-usage projection. The same table carries
the two routes that save a reviewed taskgen result.
| Method | Path | Result |
|---|---|---|
GET |
/api/skill-lab/jobs/{job_id}/results |
{summary, rows[]} for an eval job (a taskgen job returns {type: "taskgen", count, tasks, summary} instead). summary = {tasks, passed, invalid, pass_rate, soft_mean, duration_s, judge_prerequisite_missing[], token_usage}; each row = {id, task_type, hard, soft, score_valid, duration_s, judge_status, judge_reason, judge_error, error, judge_prerequisite, response (excerpt), artifacts[{path,size}], usage, judge_usage, token_usage}. The job's status is not checked: the file is served as soon as the CLI has written it (before the process exits), and 404 skill_lab.results_pending is the answer until then (also the final answer for a job that ended before scoring). |
POST |
/api/skill-lab/jobs/{job_id}/import-taskset |
Save a succeeded taskgen job's generated tasks as a NEW single-mode task set. Body {name, tasks?}; tasks is the reviewed selection — [{index, id?, question?, rubric?, task_type?}] (max MAX_TASKS_PER_SPLIT entries) where index is the row's position in the job's generated_tasks.json (strict int, no bool/string/float coercion) and the four optional fields are the only author edits accepted; unknown keys (e.g. files, attachments) are 422 validation.invalid_request. Rows absent from tasks are excluded, an omitted field keeps the generated value, task_type: "" clears it; the server rebuilds files/attachments from the job snapshot. tasks omitted or null → every generated row verbatim (legacy). 201 {job, taskset}. Errors, all before any write: 400 skill_lab.not_a_taskgen_job, 409 skill_lab.job_not_finished / skill_lab.already_imported / skill_lab.results_missing, 422 skill_lab.taskgen_empty_selection (empty tasks), 422 skill_lab.taskgen_bad_selection (index out of range or repeated), 422 skill_lab.taskgen_duplicate_id (edited ids not unique), 422 skill_lab.taskset_invalid (the validator subprocess, e.g. an unsafe id), 404 skill_lab.job_not_found outside the caller's workspace. |
POST |
/api/skill-lab/jobs/{job_id}/apply-expansion |
Append a succeeded expansion job's generated tasks to its target task set/split. Body optional: {tasks?} with the same selection shape and rules as import-taskset; no body / no tasks appends every generated row. The edited ids are re-checked against every current split of the target (409 skill_lab.expansion_conflict names the colliding ids), other splits are preserved, and the write is a validated full-replace. 200 {job, taskset}; 400 skill_lab.not_an_expansion_job, plus the same 409/422/404 family as import. Both routes leave the job's generated_tasks.json and attachment snapshot untouched. |
GET |
/api/skill-lab/jobs/{job_id}/artifacts?path= |
The job's out/ tree, any status. A directory → {kind: "dir", path, dirs[], files[{name, size}]}; a file → {kind: "text", path, size, truncated, content} (UTF-8, content capped at 512 KB with truncated: true beyond it) or {kind: "binary", path, size} (NUL byte / undecodable). A job that has not created out/ yet (queued, or running before the CLI wrote anything) answers an empty root listing, not an error; a missing or vanished sub-path is 404 skill_lab.artifact_not_found. Absolute, ~, backslash or NUL paths and anything resolving (symlinks included) outside out/ are 400 skill_lab.bad_path. |
GET |
/api/skill-lab/jobs/{job_id}/artifacts/raw?path= |
The file's exact bytes as a download (Content-Disposition filename), never capped; 404 skill_lab.artifact_not_found for a directory or missing file, same 400 skill_lab.bad_path guard. Both routes 404 skill_lab.job_not_found for a job outside the caller's workspace. |
token_usage (added). Per row: {target: <record>, judge: <record>} where
a record is {status: "reported"|"missing"|"malformed", input, cache_write, cache_read, output, unattributed} — every counter an integer or null. The
raw producer fields usage / judge_usage stay on the row unchanged.
The raw fields are made JSON-safe only where the file carried NaN /
Infinity tokens (which json.loads admits): those values are emitted as the
strings "nan" / "inf" / "-inf"; the file itself is never rewritten.
On the summary: {scope: "reported", target: <side>, judge: <side>} where a
side is {rows, reported_rows, missing_rows, malformed_rows, reports_complete, complete, input, cache_write, cache_read, output, unattributed, counter_rows{<counter>: n}, counter_complete{<counter>: bool}}.
Semantics: null is unknown (no row reported that counter), never zero. The
judge producers report input/output only, so judge cache_* is always
null. unattributed is what a transcript reported only as a total beyond
its counters (the codex form — when every counter is a zero placeholder under a
positive total, the counters are reported as null). Malformed counters (bool,
negative, NaN/inf, fractional, non-numeric) are dropped, never coerced; the
row's other valid counters still count and the row is tallied in
malformed_rows. Invalid-score rows (score_valid: false) keep their usage
in the sums while staying out of pass_rate / soft_mean. Two kinds of
completeness: reports_complete (report coverage — reported_rows == rows,
no malformed rows) and complete (breakdown completeness — reports complete
AND every counter at least one row reported was reported by every row). A
counter reported by only some rows is a partial sum: counter_rows[k] < rows, counter_complete[k] == false, and the console marks the cell k/n
(e.g. a claude row next to a codex total-only row gives input from 1 of 2
rows and complete: false even though both rows reported). A counter no row
reported is unknown and does not by itself make the breakdown partial. A
total-only report of 0 is a report (unattributed: 0, counters null), not
a missing one. scope is always reported: observed usage over the tasks that
reported it — not a billing total; no cost is estimated. Legacy results written
before usage capture show every side as missing_rows == rows with null
counters.
/api/auth/* gates the console and /api/users/* manages the accounts behind
it. Neither surface touches AWS. See
architecture.md.
| Method | Path | Auth | Result |
|---|---|---|---|
GET |
/api/auth/status |
open | {auth_required, authenticated, registration_enabled, registration_requires_approval, username, role, email, account_expires_at, permissions} — identity fields are null (permissions: []) until authenticated |
POST |
/api/auth/login |
open | Sets the launchpad_session cookie (12h, clamped to the account validity) and echoes the identity |
POST |
/api/auth/register |
open | 201 — creates a member account; by default status=pending with expires_at=null until an admin approves it, then valid for auth_registration_valid_days (default 7) |
POST |
/api/auth/logout |
session | Clears the cookie |
GET |
/api/users?q=&status=all|pending|active|expired|disabled&limit=&offset= |
admin | Paged account list with derived state / days_remaining |
GET |
/api/users/stats |
admin | Totals including the pending approval queue, expiring_soon (≤3 days), 7-day registration/sign-in counts, a 14-day registration series, top email domains |
PATCH |
/api/users/{id} |
admin | Any of status (pending|active|disabled; active on a pending account approves it and starts its window), role, extend_days, expires_at (null = never expires), password (null = generate and return once), permissions ({permission_key: bool}, null = all granted), workspaces (full replacement of the account's workspace grants, null clears them) |
DELETE |
/api/users/{id} |
admin | Removes the account |
Registration error codes: auth.registration_disabled (400, gate off or
registration disabled), auth.invalid_username / auth.invalid_email /
auth.email_domain_blocked / auth.weak_password (400),
auth.username_taken / auth.email_taken (409).
Sign-in error codes: auth.invalid_credentials (401), plus
auth.account_pending / auth.account_disabled / auth.account_expired (401)
once the submitted credentials themselves are correct.
Session and role errors: auth.required (401 — missing, tampered, or expired
cookie, and also an account that has since been disabled, expired, or deleted),
auth.forbidden (403 — member session on /api/users*), users.not_found
(404).