Custom agent loop
Most agents get a deep trace tree for free — the base image’s
auto-instrumentation captures a framework’s every step. But if you write your own tool-calling loop
with no framework, there’s nothing for the auto-instrumentation to hook, so only the launcher’s
agent.invoke boundary span appears — your reasoning, tool, and model steps are invisible. The SDK’s
step-tracing helpers close that gap: you wrap your loop’s boundaries and get the same
step → tool → model tree an auto-instrumented framework produces.
This page is the how-to for that case. If you’re using a framework, you don’t need any of this.
The idea
Section titled “The idea”client.trace gives you span scopes rooted under the launcher’s boundary span (via
W3C context propagation):
client.trace.loop(...)— the agent root scopeclient.trace.step(name)— a reasoning/chain stepclient.trace.tool(name, input=...)— a tool callllmspans are emitted for you byclient.model.chat(...)
Each scope yields a SpanHandle with .set_input(...), .set_output(...), and .set_attribute(...).
Two rules make the trace correct and the tool calls governed:
- Bind the request context so your spans nest under the inbound trace, not a new root.
- Enter the request scope so tool calls carry the invoking user’s on-behalf-of credential (skip this and they silently downgrade to org/public credentials).
serve does both for you when you hand it a handler — which is the recommended way to run a custom
loop.
Python
Section titled “Python”import ctxmeshfrom ctxmesh import agent, InvokeRequest
def handle(req: InvokeRequest) -> str: client = req.client # request scope + trace already bound by serve() messages = [{"role": "user", "content": req.input}]
with client.trace.loop("agent", headers=req.headers) as root: root.set_input(req.input) for _ in range(8): # your own max-steps guard with client.trace.step("reason"): resp = client.model.chat(model="gpt-4o", messages=messages) # emits the llm span
if not resp.has_tool_calls: root.set_output(resp.text) return resp.text
messages.append(resp.message) for call in resp.tool_calls: name = call["function"]["name"] with client.trace.tool(name, input=call) as t: out = client.tools.call(name, **_args(call)) t.set_output(out) messages.append({"role": "tool", "tool_call_id": call["id"], "content": str(out)})
return "step budget exhausted"
ctxmesh.serve(handle) # binds request_scope + trace, serves /invokeIf you serve the loop yourself instead of via serve, wrap the body in
with client.request_scope(headers=req.headers, approvals=req.approvals): and pass headers into
client.trace.loop(...) so the on-behalf-of and trace-context invariants hold.
TypeScript
Section titled “TypeScript”import { serve, type InvokeRequest } from "ctxmesh";
await serve(async (req: InvokeRequest): Promise<string> => { const client = req.client; // request scope + trace already bound const messages: Array<Record<string, unknown>> = [{ role: "user", content: req.input }];
return client.trace.loop("agent", req.headers, async (root) => { root.setInput(req.input); for (let i = 0; i < 8; i++) { const resp = await client.trace.step("reason", async () => client.model.chat("gpt-4o", messages), // emits the llm span ); if (!resp.hasToolCalls) { root.setOutput(resp.text); return resp.text; } messages.push(resp.message); for (const call of resp.toolCalls) { const name = call.function.name; const out = await client.trace.tool(name, call, async (t) => { const r = await client.tools.call(name, argsOf(call)); t.setOutput(r); return r; }); messages.push({ role: "tool", tool_call_id: call.id, content: String(out) }); } } return "step budget exhausted"; });});Add human-in-the-loop and multi-agent
Section titled “Add human-in-the-loop and multi-agent”Because you’re on the platform plane, the same primitives the managed loop uses are available in your custom loop:
pause_for_approval(key, summary)/pauseForApproval(key, summary)— pause the run at a decision point; it resumes when a human approves.client.tools.call("delegate_to", ...)/client.tools.call("handoff_to", ...)— the synthetic multi-agent tools, if the agent is a team member.
Prefer the managed loop when you can
Section titled “Prefer the managed loop when you can”The managed loop (run_managed_loop / runManagedLoop) already implements this pattern — bounded
steps, traced spans, tool dispatch, structured output, approval and delegation handling — so reach for
a custom loop only when you need control the managed loop doesn’t give you. When you do, these helpers
make your loop indistinguishable from a framework agent in the trace tree.
See also
Section titled “See also”- SDK overview · Python SDK · TypeScript SDK
- Observability model — why a no-framework loop needs these helpers
- The launcher contract — the plane your loop runs on
- Approvals · Multi-agent teams