Skip to content

Latest commit

 

History

History
843 lines (729 loc) · 93.1 KB

File metadata and controls

843 lines (729 loc) · 93.1 KB

Public API (/v1) / 公开 API

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. 密钥仅创建时展示一次,后端只保存哈希。

Sync invoke / 同步调用

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}

Streaming invoke (SSE) / 流式调用

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).

Ordinary Agent/proposal timeout_seconds defaults to 180 seconds; 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.

Python

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), 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.

Console Agents API — versions and endpoints

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 runtimeListAgentRuntimeVersions + 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.

Console System Agents API — managed presets

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.

Console Architect Assistant API — reviewed Harness proposals

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, turns, turn_in_progress, status, proposal_status, proposal_revision, created_at, updated_at}]} — the caller's own, newest first (≤ 50)
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 `{…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
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.

Evaluation-assets plan (SE-047)

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 admin 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 admin {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 admin 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 (PendingActive) 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 admin {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}], operations[{id,status,plan_revision,dataset_id,cloud_resources}], datasets[{id,name,item_count,cloud}], blockers[{kind: turn|operation|job, id, reason}], requires_admin, turns, proposals}. Ledger read only
DELETE /api/assistant/architect/conversations/{conversation_id} member (owner); admin as soon as requires_admin Delete the conversation and everything it created, in dependency order: the fenced cleanup of every cleanable evaluation-assets operation → the local Datasets those operations created (a copy synced to AWS by hand stays) → every Agent an approval deployed (the same teardown as DELETE /api/agents/{id}) → the ledger rows (operations, plans, proposals, messages, conversation). 409 assistant.conversation_busy while a turn, an operation or a deployment job is live (nothing deleted); 409 assistant.conversation_assets_remain when an operation's cleanup leaves owned resources (the conversation is kept so they stay reviewable); 403 assistant.conversation_purge_admin for a member when cloud assets or an Agent are involved. Returns {deleted, conversation_id, operations_cleaned[], datasets[], agents[{id,name,aws_resource_deleted}]}

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 (= administrator).

Console Registry API — live agent card

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:*.

Console Registry API — consumer view

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.

Console Governance API

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.reasonnot_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 statusCREATING | 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.reasondimension_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 ConflictException409 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.

Console Knowledge Bases API

/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. Nothing is stored locally, so every route 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 202CreateKnowledgeBase ({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.

Console Memory API

/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 textnull, 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/statusnot 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
POST /api/memory/resources CreateMemory ({name, description?, event_expiry_days?, strategies?, namespace_keys?}) → 201 with the detail projection in CREATING state
GET /api/memory/resources/{memory_id} Detail projection: description, status, event expiry, execution role, strategies, namespace keys
PUT /api/memory/resources/{memory_id} 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} DeleteMemory; 409 memory.platform_protected for the workspace default, 409 memory.in_use (with the agents) while a live agent's spec pins it

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).

Console Chat API

/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 (metadelta/tool/errordone); {prompt, session_id?}, a missing id starts a new session
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.

Console Evaluation Datasets API

/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 placeListDatasetExamplesDeleteDatasetExamples (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 Evaluators API

/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)
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.

Console Evaluation Runs API

/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).

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 / 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. 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
DELETE /api/eval/runs/{run_id} eval.runRemove 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/queue {running, queued, locked, max_concurrency} — cancelled runs leave the queue immediately, so the count covers active runs only

Console Online Evaluation API

/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/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

Console Observability API — on-demand session scoring

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).

Console Skill Lab API — evaluation results

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.

Console Accounts API

/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).