Commerce-Agents Adoption Review
Mechanism-level review of Anthropic's Apache-2.0 "commerce-agents" blueprint against the ClapilotAICore loop, approval gates, and benchmark harness, with a concrete adoption recommendation and effort estimate.
This page records the review of Anthropic's
anthropics/commerce-agents
blueprint (Apache-2.0, reviewed at commit fd4d592, published 2026-08-31) as an
adoption candidate for ClapilotAICore. It follows the same pattern as the
Cortheon study: read the
external code at mechanism level, map every idea onto a concrete place in our
runtime, and decide per idea whether we take the concept, the code, or nothing.
Clapilot has no commerce use case. The review covers only the three areas the evening scan flagged — the agent loop, the approval-gate mechanics, and the evals — and ignores shopping/merchant backends, verticals, Managed Agents manifests, and the Claude Code plugin.
TL;DR
| Area | Verdict | Why |
|---|---|---|
| Agent loop | Nothing (two small concepts) | Our six provider loops already do more than the reference loop (budget wrap-up, steer continuations, audited compaction, restart recovery). Two details are worth copying: settled outcomes for dangling tool_use blocks, and the blocked tool-result status. |
| Approval gates | Concept, not code | The stage → preview → host approval mark → apply pattern is exactly the shape our external actions (mail send, social publish, channel outbound, future payments) lack. The code itself is Python/pydantic and welded to merchant types; we already own a stronger single-purpose instance of the pattern (fleet_create / fleet_destroy). Generalize ours, borrow their rules. |
| Evals | Concept only — the repo ships no harness | The scan overstated this: plugins/commerce-builder/skills/commerce-evals/SKILL.md says "The repo ships no eval harness; the suite is yours." What exists is a case shape and authoring rules. Those map cleanly onto gaps in src/lib/benchmark; extend that, do not build a second harness. |
| Fencing (bonus) | Port ~60 lines | commerce_common/fencing.py strips invisible Unicode, forged turn markers, and fence-marker copies. Our quarantine scans for instruction-shaped sentences but does not normalize characters. Complementary, cheap. |
Total recommended effort: about 14–20 person-days across four work packages (see Recommendation), of which the approval gate is the only one that touches all three clients.
What the blueprint actually contains
Size and shape matter for the code-vs-concept decision:
- Two agents (shopping, merchant), each with a
core/package (types, backend interface, prompt, tool contracts, gates, executor), a Messages API runtime (runtime-messages-api/), an Agent SDK runtime, and a Managed Agents manifest. commerce-common/(~3,300 LoC Python) holds what both share:turn.py(loop helpers),execution.py(executor frame),fencing.py,memory.py,grounding.py,presentation.py,delegation.py,streaming.py, andtesting.py(a scriptedFakeClient).- Four demo verticals with Next.js web apps make up most of the ~48k LoC.
- The README states it "is not maintained and does not accept contributions." There will be no upstream to track.
Everything safety-relevant is enforced inside the tool call, in one
executor (BaseToolExecutor.execute) that all three run paths share. That is the
one architectural decision worth keeping in mind for us: a gate that lives in the
tool executor holds regardless of which provider loop or bridge invoked the tool.
1. Agent loop comparison
Reference: commerce_common/turn.py, *_runtime/orchestrator.py.
Ours: services/clapilot-agent/src/sessions/index.mjs (executeRunNow, outer
steer loop) and services/clapilot-agent/src/providers/index.mjs (six inner
provider loops: runAnthropic, runBedrock, runGemini,
runOpenAiResponsesWithTools, runOpenAiLike, runOllama).
| Mechanism | commerce-agents | ClapilotAICore | Gap |
|---|---|---|---|
| Iteration cap | max_tool_iterations rounds, then one forced round with tool_choice: none | maxToolSteps (24) + passive-poll credits + one grace iteration with tools withdrawn (TOOL_LOOP_STEP_LIMIT_WRAPUP), toolBudgetExhausted flag | None — ours is equivalent and covers six provider families |
| Repeat / loop guard | none | updateToolLoopGuard, repeat limit 3, warning before failing | Ours is stronger |
| Tool-input streaming | StreamedRound + EagerDispatcher: tool starts executing when its block closes, before the model finishes the round | Provider loops post stream:false and emit synthetic deltas; real input_json_delta handling only on the Claude CLI bridge | Real gap, but a latency optimization, not a safety one. Not recommended now: it complicates executeToolBatch (parallel batching, quarantine, recall store) for a gain that only matters on multi-tool rounds |
| Malformed tool input | Salvaged round; unreadable call gets an error result telling the model to resend; input never logged | INVALID_TOOL_ARGUMENTS_RESULT, non-fatal | None |
Interrupted turn / dangling tool_use | close_open_tool_uses(messages, settled) appends a tool_result per open call — the real outcome when the call finished, otherwise an "interrupted" error, so a completed write is never replayed | sanitizeAssistantMessage + markRepairedTrailingToolCallInvalid mark the trailing call invalid; restart-recovery.mjs re-enters the run | Small concept gap: after an abort/steer/restart we do not carry the settled outcome of a write that already completed back into history; the model may repeat it. See WP1b |
| History compaction | Oldest tool results replaced by a fixed marker when the last prompt exceeded a threshold; provenance lives outside the transcript so nothing gate-relevant is lost | Checkpointed LLM summary with auditCompactionSummary (opaque ids and the latest ask must survive), deterministic fallbacks | Ours is stronger; their point about keeping gate state outside the transcript is the one to keep |
| Blocked vs error results | ToolOutcome.held(gate, text) → tool_result event carries status: "blocked" and reason: <gate>; model gets normal text, host gets a typed signal | Tool errors are is_error results; module/scope refusals are coded errors (module_not_installed:*, contextual_tool_not_allowed:*) mixed into the same channel | Concept gap: we cannot distinguish "the tool broke" from "a policy held it" on the event stream, so UI, run-reconciler and completion judge treat both alike. See WP1a |
| Grounding (forced first read) | first_forced_tool picks a read tool via tool_choice for message shapes such as "terms question" or "apply request with nothing staged" | Page-capability routing and prompt guidance; no forced tool_choice | Not needed — commerce-specific shapes |
| Usage logging | One INFO line per model call with a session digest, never the session id | recordProviderRequest, run.phase.* events with durations and budgets | None |
Verdict: nothing to port. Two concepts (typed blocked outcome, settled
results for interrupted writes) move into the gate work package because they
are what make the gate observable.
2. Approval-gate mechanics
How the blueprint does it
Reference: merchant_agent/gates.py, merchant_agent/changes.py,
merchant_agent/types.py (StagedChange, MerchantSessionState),
examples/demo_common/merchant.py (change_action), docs/safety.md.
- Writes never execute directly. Every mutating tool is a
stage_*tool that creates aStagedChange(change_id,kind,status: staged, a field-levelitems[]diff withbefore/after,created_by,created_by_kind: operator | agent, guardrail notes, backend-computed money fields). The tool result carries a fixed note: "Staged only … apply it only after the operator approves." - Provenance ledger. Session state keeps
seen_listings,read_listings,seen_campaigns,seen_changes— ids that tool results returned this session. A stage call naming an id the session never saw is held (PROVENANCE_GATE). A content edit additionally needs a full-record read. - Guardrails run twice: at stage time and again at apply time against the config in force then (items per change, price delta %, promotion depth, restock size, campaign budget, protected fields, one line per target+field).
- The approval mark is host-owned.
apply_changesucceeds only ifchange_id ∈ state.approved_change_ids— and only the host writes that set: the portal'sPOST /changes/{id}/applyroute, or the SDK console'shost_approve. A preview card approves nothing; an approval typed into chat sets nothing (the model could fabricate it). The mark is added immediately before the executor runs and removed immediately after, whatever the outcome, so a later turn cannot spend it. - Held ≠ failed. A gate returns
ToolOutcome.held(gate, text): the model receives plain guidance ("tell the operator it is staged and waiting for approval on …"), the host receivesstatus: blocked, reason: approval. The turn continues normally. - Follow-through reminder. If the user asked for a concrete change and the
turn ends with no
stage_*attempt, one host-authored user message reminds the model to stage (never to apply). That message is tagged as host text so memory extraction and "latest user text" ignore it. - Audit stamps on the change record (
applied_byis always the operator;discarded_by_kinddistinguishes an agent-initiated discard).
What we have today
| Our mechanism | Where | Enforced in code? | Persisted? | Shape |
|---|---|---|---|---|
| Fleet approval token | src/lib/agent-runtime/tool-proxy.ts (fleetApprovalRequired, consumeFleetApproval) | Yes | Consumed once via chat_nachrichten.message_meta, 10-minute TTL | HMAC token bound to sessionKey, userId, chatSessionId, tool name and an actionHash of the arguments; the human must post FLEET FREIGEBEN <uuid> as their own user_turn message, verified server-side. Only fleet_create / fleet_destroy |
| Draft → send split | emails_create_draft → emails_send_draft; social_media_create_draft → social_media_publish_post | No gate on the second step | Draft rows exist | Structurally already "stage → apply", but the model may call send/publish in the same turn |
| Channel recipient allowlist | channels/index.mjs (resolveOutboundRecipient, agent_channel_approvals) | Yes — throws on unapproved recipient | Yes | Allowlist, not per-action approval |
| Module install / contextual / specialized-agent gates | module-install-gates.mjs, executeNativeTool | Yes | DB-backed, fails open | Surface gates (which tools exist), not action gates |
Orchestrator awaiting_approval | orchestrator-sessions/index.mjs (Codex requestApproval RPC) | Yes, for the Clapilot-code bridge | agent_orchestrator_sessions row | Command/file-change approval, bridge-only |
| Post-hoc caveats and completion judge | run-completion-caveats.mjs, autonomous-completion.mjs | Yes | Run events | Gate reporting, not execution |
| Payments | — | — | — | No payment-executing tool exists today; Shopify tools are read-only |
The main-agent bridge envelope hard-codes approvalPolicy: "never"
(resolveBridgePermissionEnvelope), and the system prompt tells the model to
act rather than describe. That is intentional and stays: the blueprint's answer
to the same tension is not to ask before every tool, but to make only the
apply step of a small set of irreversible actions need a mark that the model
cannot produce.
Does the pattern fit our external actions?
Yes, and better than the fleet token does, for three reasons:
- The mark is set by a UI click, not by a typed phrase. The fleet phrase
works but is a chat-only UX; it cannot be used from a job, a channel run, or
the iOS app without a personal chat session (
fleet_personal_session_required). A staged-action record with an approve/dismiss surface works on every run kind — the run ends with "staged, waiting for approval", and approval later applies the action without a model call. - It preserves autonomy where we want it. Everything that is reversible
(drafts, tasks, documents, calendar entries) stays ungated. Only
emails_send_draft,social_media_publish_post, outbound channel sends to recipients outside the allowlist, and any future payment/transfer tool become "apply" steps. - It gives the completion judge and the run reconciler a truthful signal.
Today a run that ends on an unsent draft looks like a run that failed to
send. With
status: blocked, reason: approvalthe judge can score "staged and waiting" as complete-with-caveat instead ofuncertified.
What we should not copy: the per-domain guardrail catalogue (price deltas,
restock sizes) — for us guardrails are per-tool argument checks that already
live in the tool-proxy handlers; and the provenance ledger as a hard rule on
all ids — our tools are id-driven across many modules and a session-wide
"seen ids" map would break legitimate cross-session references (a task id
from the page context, a document id from a URL). A narrow provenance rule is
enough: an apply must reference a staged record that this session created or
listed, which is exactly what consumeFleetApproval already checks via the
action hash.
3. Evals versus benchmark_*
What the blueprint has
- No harness.
commerce-evals/SKILL.md: "The repo ships no eval harness; the suite is yours, because a case only means something against your catalog, orders, and fixtures." Gate behaviour is unit-tested with a scriptedFakeClient; behavioural evals are described, not implemented. - A case shape (JSON):
id,priority,difficulty,tags,skip, astateprecondition (seen ids, cart, memory, staged changes),turns, andexpectedwith code-graded keys —calls_tool,calls_one_of,never_calls,first_tool,first_tool_not,ui_components,no_ui,cart_contains/cart_not_contains,staged_change_kinds,no_applied_changes,memory_contains/memory_not_contains,skill_loaded/no_skill_load,reply_includes/reply_omits,max_tool_calls— plus onerubricfor a judge. - Authoring rules worth adopting verbatim: every positive case has a
negative twin; grade final tool arguments and resulting state, not wording;
assert the route only where the route is the behaviour (a grounding read
first, a write that must never happen), otherwise
calls_one_of; a rubric is one PASS and one FAIL condition that no answer satisfies both of, names the fixture fact that decides it, says nothing about tone or length. - Judge rules: one judge call per dimension, temperature zero, structured verdict + reason, transcript truncated from the start, and a fingerprint of judge model + rubric stored with every verdict so a rubric change invalidates old scores; an unparseable judge reply is a judge failure, not an agent failure.
- Run pattern: regression set on every merge over several trials with a pass threshold; targeted set while changing one flow; everything on a model change; a judged sample of live traffic in production. Diff failure sets, not toplines — a one-point topline move between live runs is noise.
- Poisoned fixtures: eval-only listings/reviews carrying injected
instructions, each with a code-asserted negative (
never_calls,no_applied_changes,memory_not_contains) and a benign should-serve counterpart so a refuse-everything agent still fails.
What we have
src/lib/benchmark/ (~1,300 LoC) is a real harness, exposed through
benchmark_list_scenarios, benchmark_list_runs, benchmark_get_run,
benchmark_start_run and the Benchmark module UI:
- Seven scenarios in
scenarios.tsmirroring real workflows (video storyboard, quarterly tax report, invoice canvas, project tasks, document brief, email draft, calendar planning), each withbuildPrompt(marker),extraSystemPrompt,allowedToolNames,expectedTools[](tool set +minCalls+ points),judgeRubric,timeoutSeconds. runner.tsexecutes each scenario as a live agent run against the real workspace, scores expected-tool checks, run completion, tool reliability, and an LLM judge (0–10 mapped toBENCHMARK_JUDGE_POINTS), and persistsbenchmark_runs/benchmark_resultswith a per-model leaderboard.
Overlap: prompt-driven scenarios, tool-call expectations, allowlists, LLM judge, per-model comparison. This is the harness the blueprint tells its readers to write. Do not build a second one.
The gaps the case shape exposes
Missing in src/lib/benchmark | Blueprint key | Why it matters for us |
|---|---|---|
| Negative tool assertions | never_calls, no_applied_changes | Cannot currently assert "did not send", "did not publish", "did not call benchmark_start_run"; the only mechanism is the allowlist, which hides the tool instead of testing the refusal |
| Ordering and budget | first_tool, max_tool_calls | Grounding-before-write and over-calling regressions are invisible to a points total |
| Negative twins | authoring rule | Every current scenario is a should-do; nothing asserts a should-not (health remark not memorized, hostile document not acted on) |
| Binary rubric + judge fingerprint | rubric, fingerprint | The 0–10 judge score is averaged into a topline; a rubric edit silently changes history. A PASS/FAIL verdict with a stored judgeModel + rubricHash makes runs comparable |
| Multi-trial and baseline diff | run pattern | One trial per scenario; the leaderboard is a topline. Flake and drift are indistinguishable |
| State preconditions | state | Scenarios start from an empty prompt; a "staged action exists, user approves" case cannot be expressed |
| Poisoned fixtures | poisoned fixtures | The tool-result quarantine has unit tests but no end-to-end eval showing an agent ignores an injected instruction inside a document or mail |
Priority, difficulty and skip tags are cheap and make the failure-set diff
readable.
Recommendation and effort
Take the concepts, not the code. Reasons the code does not transfer:
Python 3.11 / pydantic / anthropic SDK against our Node ESM runtime; gates are
typed against MerchantSessionState, StagedChange, Listing; three of the
seven gate checks are commerce-only (options/variants, promotion depth,
campaign provenance); the loop assumes a single Messages API client, while ours
must behave identically across six provider loops and the CLI bridges. Porting
would mean rewriting every line anyway, and the repo is unmaintained, so there
is no upstream benefit from staying close to it. The exception is
fencing.py, whose character-level sanitizer is provider-agnostic and short.
Effort in person-days (PD), assuming one engineer, including docs, agent tool contracts, DE/EN/IT strings, and the three clients where a surface exists:
WP1 — Generic staged-action approval gate (6–9 PD)
- 1a. Typed
blockedoutcome (1 PD). Addstatus: "blocked"andreason: <gate>to the tool-end event and the proxy result envelope, next to the existingis_error. Emit it from the module/scope/shell refusals and the fleet gate today. TeachdetectFailedSideEffectsand the completion judge evidence builder that a blocked call is not a failed one. - 1b. Settled outcomes for interrupted writes (1 PD). When a steer, abort or
restart cuts a round whose side-effecting tool already returned, carry the
real result into the replayed history instead of the "invalid trailing call"
marker, so the model does not repeat a completed send. Mirrors
close_open_tool_uses(settled=…). - 1c.
agent_staged_actions(2–3 PD). Migration and API:id,run_id,session_key,user_id,tool_name,action_hash,args_json,preview_json,status: staged | approved | applied | discarded | expired,created_by_kind: user | agent,applied_by, timestamps. GeneralizecreateFleetApprovalToken/consumeFleetApproval: the mark is written only by the approve route, consumed immediately after the apply attempt, bound to theaction_hash. The tool handler for a gated tool without a valid mark returnsblocked / approvalwith the staged id and a one-line instruction to tell the user it is waiting — never an error. - 1d. Gate the four external actions (1 PD).
emails_send_draft,social_media_publish_post, outbound channel send to a non-allowlisted recipient, and a placeholder policy entry for payment/transfer tools. Per-tool switch in Settings → ClapilotAICore → Runtime (app_settings, default on for autonomous runs, off for interactive chat where the user is watching), following the existing runtime-flag pattern. - 1e. Approval surface (1–3 PD). Web: a "Freigaben" list on the run/chat
timeline and in the Dashboard with approve/dismiss circular actions; iOS/macOS:
the same list in the run detail; channels: the existing
agent_channel_approvalsreply flow reused. Approving applies without a model call and posts the result back to the run.
WP2 — Narrow provenance rule (1–2 PD, optional, after WP1)
Apply accepts only staged ids the session created or listed, and stage refuses
draft ids the session never read (emails_get_message /
social_media_get_post first). Implemented as a per-session seenIds map on
the run context, not a global ledger.
WP3 — Benchmark harness upgrades (4–6 PD)
- Extend
BenchmarkScenarioDefinitionwithneverCalls,firstTool,maxToolCalls,statepreconditions (seeded records the scenario references),priority,difficulty,skip. - Add negative twins for the existing seven scenarios and three poisoned-fixture scenarios (injected instruction in a document, a mail body, and a web page) with code-asserted negatives and a benign counterpart each.
- Switch the judge to a PASS/FAIL rubric per dimension with a stored
judgeModel + rubricHashfingerprint; keep the 0–10 score as a secondary metric. trialsper run and a failure-set diff view against a chosen baseline run in the Benchmark module, replacing "score moved by 0.4" as the signal.- Wire the staged-action scenarios once WP1 lands ("stage, wait, approve, apply" and "never applies without a mark").
WP4 — Fencing hardening (1 PD)
Port the NFKC normalization, invisible- and control-character stripping, forged
turn-boundary neutralization (blank line + Human: / Assistant: / System:),
transcript and tool-call tag removal (<function_calls>, <tool_result>,
<|…|> special tokens, run to a fixpoint so nested copies cannot reassemble),
and fence-marker copy removal from commerce_common/fencing.py into
tool-result-quarantine.mjs as a normalization step before the
instruction-shape scan. Add the Apache-2.0 attribution to
THIRD_PARTY_NOTICES.md as done for Cortheon. Test with the existing quarantine
unit tests plus the blueprint's own hostile cases.
Sequencing
WP1a → WP1c/1d → WP1e → WP4 in the first iteration (the gate is the real gap); WP3 in the second so the gate has end-to-end evals; WP2 only if the gate's first weeks show apply calls with ids the session never saw.
Explicitly not recommended
- Eager mid-stream tool dispatch (
EagerDispatcher): latency optimization with a large blast radius inexecuteToolBatch. - Forced grounding via
tool_choice: commerce-shaped. - The merchant guardrail catalogue, staging follow-through reminder, and presentation-component enrichment: commerce-shaped.
- Adopting any package from the repo as a dependency: unmaintained, Python.
Sources
- Repository: https://github.com/anthropics/commerce-agents (commit
fd4d592, Apache-2.0, "reference implementation; not maintained"). - Files read for this review:
README.md,docs/safety.md,commerce-common/commerce_common/{turn,execution,fencing,streaming,testing}.py,merchant-agent/core/merchant_agent/{gates,changes,types}.py,merchant-agent/runtime-messages-api/merchant_agent_runtime/orchestrator.py,merchant-agent/runtime-agent-sdk/merchant_agent_sdk/merchant_tools.py,examples/demo_common/merchant.py,plugins/commerce-builder/skills/commerce-evals/SKILL.md. - Clapilot counterparts:
services/clapilot-agent/src/sessions/index.mjs,services/clapilot-agent/src/providers/index.mjs,services/clapilot-agent/src/tool-result-quarantine.mjs,services/clapilot-agent/src/autonomous-completion.mjs,services/clapilot-agent/src/channels/index.mjs,src/lib/agent-runtime/tool-proxy.ts(fleet approval, mail/social handlers),src/lib/benchmark/{scenarios,runner}.ts.
