Skip to content

Commit 965aafd

Browse files
nficanoclaude
andcommitted
recipes: add composed-feature recipes mirroring other SDK shapes
Adds the four cross-SDK recipes (email-vendor-leases, multi-agent-budget, stream-resume, mcp-skill) so python-sdk matches typescript-sdk, go-sdk, rust-sdk, java-sdk, kotlin-sdk, csharp-sdk, fsharp-sdk, php-sdk, and swift-sdk. Each recipe wires a real provider SDK (Anthropic, OpenAI, GLM-5 via z.ai, MCP) around ARCP features. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f9b9f9c commit 965aafd

9 files changed

Lines changed: 824 additions & 0 deletions

File tree

recipes/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Recipes
2+
3+
Composed ARCP features wired around a real LLM workload. Unlike the
4+
single-feature [`examples/`](../examples/) — which use toy agents (echo,
5+
cost-counter, slow timer) — each recipe is a complete end-to-end shape
6+
with an actual provider SDK driving the agent.
7+
8+
## [multi-agent-budget/](multi-agent-budget/) — OpenAI
9+
10+
The planner decomposes a question into sub-questions and delegates each
11+
to a worker carrying a budget slice carved from its own remaining cap.
12+
After each grant the planner emits a `cost.delegate` metric on itself
13+
so the runtime's subset check at the next delegate sees an honest
14+
remaining balance. Workers that overspend trip `BUDGET_EXHAUSTED`;
15+
sub-questions that no longer fit are skipped before the delegate.
16+
17+
## [email-vendor-leases/](email-vendor-leases/) — Claude
18+
19+
A triage agent runs Claude through a tool-use loop with three tools, but
20+
the lease grants only the two read-only ones. When the model proposes
21+
`send_reply` the agent's `ctx.authorize("tool.call", ...)` raises
22+
`PermissionDeniedError` and feeds the denial back to Claude, which
23+
observes the deny and returns a drafted-but-unsent reply. Each
24+
`inbox_read` also emits an `x-vendor.acme.email.parsed` event so
25+
dashboards recognising the namespace can render parsed metadata
26+
specially.
27+
28+
## [stream-resume/](stream-resume/) — GLM-5
29+
30+
The writer pipes GLM-5's streaming deltas into `ctx.stream_result()`,
31+
batching ~200 chars per `result_chunk` envelope. Every envelope lands in
32+
the runtime's `EventLog` under a monotonic `event_seq`. The client drops
33+
the transport mid-stream, opens a fresh session with `client.resume()`,
34+
and the runtime replays every envelope past the cutoff so reassembly
35+
completes seamlessly across the gap.
36+
37+
## [mcp-skill/](mcp-skill/) — MCP bridge
38+
39+
An MCP server fronts the [multi-agent-budget](multi-agent-budget/)
40+
planner so any MCP host (Claude Code, Cursor, Desktop) can call it as a
41+
single `research` tool. The bridge keeps one long-lived ARCP session;
42+
each MCP tool invocation submits a fresh planner job and returns the
43+
terminal result as the tool's text response. A Claude Code skill at
44+
[skills/research/SKILL.md](mcp-skill/skills/research/SKILL.md) tells the
45+
model when to reach for the tool.
46+
47+
## Running
48+
49+
Each recipe pairs a server and a client. Open two terminals:
50+
51+
```
52+
python recipes/<name>/server.py # terminal 1
53+
python recipes/<name>/client.py # terminal 2
54+
```
55+
56+
Provider SDKs (`anthropic`, `openai`, `mcp`) are not pinned in
57+
`pyproject.toml` because they are not core dependencies — install
58+
whichever ones the recipe you want to run needs.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""email-vendor-leases client — submit triage with a lease that omits send_reply.
2+
3+
Submits the triage task with a lease that allows the read-only tools
4+
but deliberately omits send_reply, so Claude's eventual attempt to
5+
send hits PERMISSION_DENIED and degrades gracefully.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
import contextlib
12+
import os
13+
import sys
14+
15+
from arcp import ClientInfo, WebSocketTransport
16+
from arcp.client import ARCPClient
17+
18+
PORT = int(os.environ.get("ARCP_DEMO_PORT", "7900"))
19+
URL = os.environ.get("ARCP_DEMO_URL", f"ws://127.0.0.1:{PORT}/arcp")
20+
TOKEN = os.environ.get("ARCP_DEMO_TOKEN", "demo-token")
21+
22+
23+
async def main() -> int:
24+
client = ARCPClient(
25+
client=ClientInfo(name="triage-client", version="1.0.0"),
26+
token=TOKEN,
27+
features=(),
28+
)
29+
async with contextlib.aclosing(client):
30+
transport = await WebSocketTransport.connect(URL)
31+
await client.connect(transport)
32+
# the lease grants tool.call only for read-only inbox tools. send_reply
33+
# is intentionally absent — when Claude proposes that tool the agent's
34+
# ctx.authorize raises PermissionDenied and a tool_result error is fed
35+
# back. the model recovers and returns a drafted (not-sent) reply.
36+
handle = await client.submit(
37+
agent="triage",
38+
input={},
39+
lease_request={"tool.call": ["inbox_list", "inbox_read"]},
40+
)
41+
async for ev in handle.events():
42+
if ev["kind"] == "tool_result" and ev["body"].get("error"):
43+
print(f"denied: {ev['body']['error']['message']}")
44+
elif ev["kind"] == "x-vendor.acme.email.parsed":
45+
print(f"parsed: {ev['body']['subject']} (urgency={ev['body']['urgency']})")
46+
result = await handle.done
47+
print(f"terminal: {result.final_status} drafted={result.result}")
48+
return 0
49+
50+
51+
if __name__ == "__main__":
52+
sys.exit(asyncio.run(main()))
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""email-vendor-leases — Claude tool-use loop with a lease that denies send_reply.
2+
3+
A triage agent receives an "inbox check" task with a lease that grants
4+
read-only tools but NOT send_reply. Claude reads each message, emits a
5+
vendor-extension event per parsed message so dashboards can render
6+
them specially, and eventually decides one needs a reply. When it
7+
tries to call send_reply the lease check denies it; Claude observes
8+
the PERMISSION_DENIED tool_result and degrades to drafting the reply
9+
for human review.
10+
11+
Highlights: §13.4 lease violation as a *recoverable* tool_result error
12+
(not session-fatal), §15 / §8.2 x-vendor.* event-kind namespace, and
13+
a realistic Claude tool-use loop that handles a deny without crashing.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import asyncio
19+
import os
20+
from typing import Any
21+
22+
import anthropic
23+
24+
from arcp import PermissionDeniedError, RuntimeInfo, serve_websocket
25+
from arcp.runtime import ARCPRuntime, JobContext, StaticBearerVerifier
26+
27+
PORT = int(os.environ.get("ARCP_DEMO_PORT", "7900"))
28+
TOKEN = os.environ.get("ARCP_DEMO_TOKEN", "demo-token")
29+
30+
TOOLS = [
31+
{
32+
"name": "inbox_list",
33+
"description": "List recent unread messages.",
34+
"input_schema": {"type": "object", "properties": {}},
35+
},
36+
{
37+
"name": "inbox_read",
38+
"description": "Read one message by id.",
39+
"input_schema": {
40+
"type": "object",
41+
"properties": {"id": {"type": "string"}},
42+
"required": ["id"],
43+
},
44+
},
45+
{
46+
"name": "send_reply",
47+
"description": "Send a reply to a message.",
48+
"input_schema": {
49+
"type": "object",
50+
"properties": {"id": {"type": "string"}, "body": {"type": "string"}},
51+
"required": ["id", "body"],
52+
},
53+
},
54+
]
55+
56+
# stand-in inbox so the recipe is self-contained — swap for IMAP/Gmail in real use
57+
INBOX = {
58+
"m1": {"id": "m1", "from": "ops@acme.dev", "subject": "Status", "body": "All quiet.", "urgency": "low"},
59+
"m2": {"id": "m2", "from": "ceo@acme.dev", "subject": "Outage!", "body": "Site is down — fix asap.", "urgency": "high"},
60+
}
61+
62+
63+
async def run_tool(name: str, args: dict[str, Any]) -> Any:
64+
if name == "inbox_list":
65+
return [{"id": m["id"], "subject": m["subject"], "from": m["from"]} for m in INBOX.values()]
66+
if name == "inbox_read":
67+
return INBOX[args["id"]]
68+
raise RuntimeError(f"tool {name} should have been denied before reaching run_tool")
69+
70+
71+
async def triage_agent(_input: dict, ctx: JobContext) -> dict:
72+
client = anthropic.AsyncAnthropic()
73+
messages: list[dict[str, Any]] = [
74+
{
75+
"role": "user",
76+
"content": "Triage my inbox. Read each unread message and reply to anything urgent.",
77+
}
78+
]
79+
80+
# tool-use loop: Claude proposes a tool call, we authorize against the
81+
# lease, run it (or surface a denial), feed the result back, repeat.
82+
while True:
83+
turn = await client.messages.create(
84+
model="claude-sonnet-4-6",
85+
max_tokens=1024,
86+
tools=TOOLS,
87+
messages=messages,
88+
)
89+
90+
if turn.stop_reason == "end_turn":
91+
text = next((b.text for b in turn.content if b.type == "text"), "")
92+
return {"drafted_reply": text, "sent": False}
93+
94+
# append the assistant turn so the next call has full context
95+
messages.append({"role": "assistant", "content": [b.model_dump() for b in turn.content]})
96+
tool_results: list[dict[str, Any]] = []
97+
98+
for block in turn.content:
99+
if block.type != "tool_use":
100+
continue
101+
102+
await ctx.tool_call({"tool_call_id": block.id, "tool": block.name, "args": block.input})
103+
104+
try:
105+
# the lease grants tool.call only for the read-only tools; the
106+
# send_reply pattern is absent so this raises PermissionDenied
107+
ctx.authorize("tool.call", block.name)
108+
except PermissionDeniedError as err:
109+
# surface the denial on the ARCP stream as a recoverable error...
110+
await ctx.tool_result(
111+
{
112+
"tool_call_id": block.id,
113+
"error": {"code": err.code, "message": str(err), "retryable": False},
114+
}
115+
)
116+
# ...and hand it to Claude as the tool result so the model can
117+
# recover gracefully — lease violations are not session-fatal
118+
tool_results.append(
119+
{
120+
"type": "tool_result",
121+
"tool_use_id": block.id,
122+
"content": f"denied: {err}",
123+
"is_error": True,
124+
}
125+
)
126+
continue
127+
128+
result = await run_tool(block.name, block.input)
129+
if block.name == "inbox_read":
130+
# vendor-extension event — dashboards that recognise the
131+
# x-vendor.acme.* namespace render parsed metadata specially
132+
await ctx.job.emit_event(
133+
"x-vendor.acme.email.parsed",
134+
{
135+
"message_id": result["id"],
136+
"from": result["from"],
137+
"subject": result["subject"],
138+
"urgency": result["urgency"],
139+
},
140+
)
141+
await ctx.tool_result({"tool_call_id": block.id, "output": result})
142+
tool_results.append(
143+
{"type": "tool_result", "tool_use_id": block.id, "content": str(result)}
144+
)
145+
146+
messages.append({"role": "user", "content": tool_results})
147+
148+
149+
async def main() -> None:
150+
runtime = ARCPRuntime(
151+
runtime=RuntimeInfo(name="email-triage", version="1.0.0"),
152+
bearer=StaticBearerVerifier({TOKEN: "demo-principal"}),
153+
)
154+
runtime.register_agent("triage", triage_agent)
155+
server = await serve_websocket(runtime.accept, host="127.0.0.1", port=PORT, path="/arcp")
156+
print(f"listening on ws://127.0.0.1:{PORT}/arcp")
157+
try:
158+
await asyncio.Future()
159+
finally:
160+
server.close()
161+
await server.wait_closed()
162+
await runtime.close()
163+
164+
165+
if __name__ == "__main__":
166+
asyncio.run(main())

recipes/mcp-skill/server.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""mcp-skill — bridge an MCP `research` tool to the multi-agent-budget planner.
2+
3+
An MCP server that bridges to the multi-agent-budget runtime, exposing
4+
the ARCP planner as a single `research` tool. The Claude Code skill in
5+
skills/research/SKILL.md describes when to invoke the tool; this file
6+
is the runtime bridge it ends up calling.
7+
8+
Highlights: the seam between MCP (model-side tool surface) and ARCP
9+
(runtime-side agent execution). One long-lived ARCP session per MCP
10+
process; each MCP tool call submits a fresh ARCP job through it. The
11+
agent's eventual lease, cost cap, and delegation tree are entirely
12+
ARCP concerns — MCP just sees one call in, one result out.
13+
14+
Run the multi-agent-budget server first, then point an MCP host at
15+
this script.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import asyncio
21+
import json
22+
import os
23+
from typing import Any
24+
25+
from mcp.server import Server
26+
from mcp.server.stdio import stdio_server
27+
from mcp.types import TextContent, Tool
28+
29+
from arcp import ClientInfo, WebSocketTransport
30+
from arcp.client import ARCPClient
31+
32+
PORT = int(os.environ.get("ARCP_DEMO_PORT", "7899"))
33+
URL = os.environ.get("ARCP_DEMO_URL", f"ws://127.0.0.1:{PORT}/arcp")
34+
TOKEN = os.environ.get("ARCP_DEMO_TOKEN", "demo-token")
35+
36+
37+
async def main() -> None:
38+
# one ARCP session for the lifetime of the bridge process. each MCP
39+
# tool call submits a new job through this session.
40+
arcp = ARCPClient(
41+
client=ClientInfo(name="mcp-bridge", version="1.0.0"),
42+
token=TOKEN,
43+
features=("cost.budget",),
44+
)
45+
transport = await WebSocketTransport.connect(URL)
46+
await arcp.connect(transport)
47+
48+
mcp = Server("arcp-research-bridge")
49+
50+
@mcp.list_tools()
51+
async def _list_tools() -> list[Tool]:
52+
# advertise one tool. the MCP host (Claude Code / Cursor / Desktop)
53+
# reads this schema and presents it to the model as a callable tool.
54+
return [
55+
Tool(
56+
name="research",
57+
description=(
58+
"Decompose a research question into sub-questions and answer "
59+
"each under a shared cost cap. Returns the plan, delegated "
60+
"sub-questions, and any dropped for budget."
61+
),
62+
inputSchema={
63+
"type": "object",
64+
"properties": {
65+
"question": {"type": "string"},
66+
"budget_usd": {"type": "number", "default": 0.5},
67+
},
68+
"required": ["question"],
69+
},
70+
)
71+
]
72+
73+
@mcp.call_tool()
74+
async def _call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
75+
# tool invocation: forward into ARCP and shape the terminal result
76+
# back as an MCP tool response.
77+
if name != "research":
78+
raise ValueError(f"unknown tool: {name}")
79+
budget = float(arguments.get("budget_usd", 0.5))
80+
handle = await arcp.submit(
81+
agent="planner",
82+
input={"question": arguments["question"]},
83+
lease_request={
84+
"cost.budget": [f"USD:{budget:.2f}"],
85+
"tool.call": ["llm.complete"],
86+
"agent.delegate": ["worker"],
87+
},
88+
)
89+
result = await handle.done
90+
# MCP tool responses are an array of content blocks; here we emit a
91+
# single text block carrying the planner's JSON result.
92+
return [TextContent(type="text", text=json.dumps(result.result, indent=2))]
93+
94+
# MCP servers typically speak stdio to their host process.
95+
async with stdio_server() as (read, write):
96+
await mcp.run(read, write, mcp.create_initialization_options())
97+
98+
await arcp.close()
99+
100+
101+
if __name__ == "__main__":
102+
asyncio.run(main())

0 commit comments

Comments
 (0)