Building an OpenAI Agents agent on Yutha¶
A walkthrough for developers and AI engineers who want to put OpenAI Agents agents on top of Yutha's coordination substrate. Companion to the LangGraph guide and the CrewAI guide — same substrate, different framework idioms. The distinctive piece here is OpenAI Agents' handoff primitive: every inter-agent transition the LLM performs becomes a signed Yutha envelope on the audit log.
By the end you'll have:
- A signed, registered OpenAI Agents agent talking to a real Yutha control plane.
- Two
agents.Agentinstances exchanging control via handoffs, with each handoff producing a signed envelope between distinct Yutha identities. - A
function_toolcallable wrapped with@capability_requiredso server-side capability gates fire when the tool emits a Yutha envelope. - A query against the audit log showing the full handoff chain plus every cap-check that gated it.
What you get for free. Every message — including each
handoff-audit envelope the bridge emits — is Ed25519-signed by
its sender. Every send, every delivery, every capability check,
every admission/revocation produces a content-addressed receipt.
The 1:1 mapping between OpenAI Agents Agent instances and
Yutha agents means the audit log records which agent handed off
to which, when, and with what conversation state — not a
Runner-level aggregate.
What you write. Three things: per-agent passports, the
agents.Agent instances themselves (with their instructions,
tools, and handoffs lists), and optional
@capability_required wrappers on sensitive function_tool
callables. Everything else — bearer-token minting, signature
verification, the handoff bridge, stream multiplexing, receipt
emission — happens below the SDK surface.
If you haven't built an agent on top of Yutha before, the
LangGraph guide is a gentler starting point (its
handler is a synchronous state graph with no LLM dependency).
This walkthrough assumes you're already comfortable with OpenAI
Agents' Agent / Runner / function_tool / handoffs model.
Prerequisites¶
You need four things on hand before any of the snippets work.
1. A running control plane in open admission mode:
export YUTHA_BOOTSTRAP_SEED=$(python -c \
'import secrets; print(secrets.token_hex(32))')
cargo run -p yutha-control-plane -- --admission-mode open
2. The Python SDK installed with the openai-agents extra:
The openai-agents extra pulls the upstream OpenAI Agents SDK
in (which itself brings the OpenAI Python SDK, litellm for
cross-provider model support, and tracing/MCP transitive
dependencies). The base yutha install doesn't carry any of
that — see the rationale in
pyproject.toml.
Working from a repo clone instead of PyPI? Use an editable
install:
cd sdks/python && uv pip install -e '.[dev,openai-agents]'.
A note on import paths: the PyPI package is openai-agents,
but the importable Python module is agents. So it's
from agents import Agent, Runner, function_tool — not
from openai_agents import .... The Yutha adapter follows the
same convention internally.
3. The same YUTHA_BOOTSTRAP_SEED exported in your dev shell
so derived swarm_ids match.
4. An OpenAI Agents LLM credential. Unlike the LangGraph adapter (whose handler can be a deterministic state graph), the OpenAI Agents Runner always invokes an LLM on each turn. The simplest default is OpenAI:
Other providers work via the framework's LiteLLM integration — see the upstream OpenAI Agents docs for switching defaults. The Yutha adapter is LLM-agnostic; the choice only affects how each Agent reasons.
Step 1 — Your first OpenAI Agents agent on Yutha¶
The smallest useful pattern: mint a passport, connect, register
an agents.Agent under a Yutha identity, send a message to
yourself, and have the dispatch loop convert it into a
Runner.run call.
import asyncio
import hashlib
import os
import secrets
from agents import Agent
import yutha
from yutha.openai_agents import YuthaOpenAIAgent
async def main():
# Derive swarm_id from the bootstrap seed.
seed = bytes.fromhex(os.environ["YUTHA_BOOTSTRAP_SEED"])
swarm_id = yutha.SwarmId(value=hashlib.sha256(seed + b"\x02").digest()[:16])
# Mint a fresh identity for this OpenAI Agents agent.
signing_key = yutha.SigningKey.generate()
agent_id = yutha.AgentId(value=secrets.token_bytes(16))
passport = yutha.Passport(
spec_version="1.0.0",
agent_id=agent_id,
swarm_id=swarm_id,
agent_public_key=signing_key.public_key(),
owner="hello-yutha-openai-agents",
framework="openai-agents",
framework_version="0.x",
accepted_constitution_version="1.0.0",
tier=yutha.PassportTier.MINIMAL,
issued_at=yutha.Timestamp.now(),
expires_at=yutha.Timestamp(
wall_clock="2099-01-01T00:00:00Z", monotonic_ns=2**62
),
).sign(signing_key)
# Construct an OpenAI Agents Agent. instructions/tools/handoffs
# drive the LLM reasoning; for the substrate-level integration
# these are informational — what matters is that the wrapper
# gets a real Agent instance to invoke Runner.run against.
oai_agent = Agent(
name="Greeter",
instructions=(
"You receive a short greeting and reply with a single "
"short sentence acknowledging it."
),
)
# Wrap and connect. Lifecycle mirrors yutha.crewai.YuthaCrewAgent
# and yutha.langgraph.YuthaAgent — register, async-context
# start + stop, send to emit envelopes.
async with YuthaOpenAIAgent.connect(
"127.0.0.1:50051",
passport=passport,
signing_key=signing_key,
oai_agent=oai_agent,
) as agent:
await agent.register()
await asyncio.sleep(0.1) # let subscription settle
# Send a message to ourselves. Returns the envelope.send
# receipt id.
receipt = await agent.send(
recipient=yutha.Recipient.for_agent(agent_id),
performative=yutha.Performative.INFORM,
payload=b"hello from an OpenAI Agents agent",
payload_schema_id="type.yutha.dev/v1/Text",
)
print(f"sent; receipt = {receipt.digest.hex()[:16]}...")
# The dispatch loop receives the envelope and, by default,
# converts its payload to a UTF-8 string and passes it to
# Runner.run on the wrapped Agent. Wait a moment so the
# LLM round-trip has time to complete.
await asyncio.sleep(5.0)
asyncio.run(main())
A few things to note:
- The passport's
frameworkfield is"openai-agents". Useful for audit-log filtering and analytics later on; doesn't affect any wire semantics. - The signing key never leaves your process. The Yutha control plane verifies every envelope's signature against the public key on the passport.
YuthaOpenAIAgentis not itself anagents.Agent. It wraps one. The wrapping is deliberate: the wrapper owns the gRPC channel, the registration, the subscribe stream, theRunHooksinstance, and the signing key — none of which OpenAI Agents cares about.- No sync/async bridge. OpenAI Agents'
Runner.runis fully async, so the dispatch loop justawaits it directly. No thread-offload, norun_coroutine_threadsafe— contrast the CrewAI guide where synckickoff()forces a worker thread.
Step 2 — The dispatch loop and the RunHooks handoff bridge¶
This is the part that's distinctively OpenAI Agents. When
YuthaOpenAIAgent.start() runs, it opens a subscribe stream and
for each inbound envelope it:
- Asks the input factory to convert the envelope into the
inputargument for the nextRunner.runcall. The default factory decodesenvelope.payloadas UTF-8 and returns it as a single-turn user message — and explicitly skips handoff-audit envelopes (more on those below) so the bridge doesn't cascade into spurious extra runs. - Invokes
Runner.run(self._oai_agent, input, hooks=...)with a lazily-constructedYuthaRunHooksattached. The hooks subclassagents.lifecycle.RunHooksBaseand ride along through the entire turn — including any handoffs. - Hands the resulting
RunResultto your optionalon_outputcallback if you supplied one, which can emit follow-on envelopes viaagent.send(...).
The bridge does not emit substrate receipts on tool
boundaries or agent start/end. OpenAI Agents ships its own
tracing for those, and adding parallel substrate receipts would
just multiply audit noise without adding signal. The
substrate-relevant tool event is the Send a cap-gated tool
makes, which produces its own capability.check.* receipt
naturally.
How handoffs cross the wire¶
OpenAI Agents represents multi-agent flows as a graph of
Agent instances joined by handoffs=[other_agent, ...]
lists. When the Runner decides (LLM-driven) to transfer control
from agent A to agent B, it invokes
on_handoff(context, from_agent, to_agent) on every attached
hooks instance. The Yutha bridge's on_handoff implementation
turns each transition into a signed Yutha envelope:
# Inside YuthaRunHooks (yutha/openai_agents/hooks.py):
async def on_handoff(self, context, from_agent, to_agent):
from_name = getattr(from_agent, "name", "?")
to_name = getattr(to_agent, "name", "?")
# Look up the source wrapper so the audit envelope is
# attributed to the actual source agent, not always to the
# wrapper that owns the hooks. Falls back to the owning
# wrapper when the source isn't registered.
source_wrapper = registry.get(from_name, wrapper)
peer = registry.get(to_name)
recipient = (
Recipient.for_agent(peer.agent_id)
if peer is not None
else Recipient.for_agent(source_wrapper.agent_id)
)
await source_wrapper.send(
recipient=recipient,
performative=Performative.INFORM,
payload=f"handoff: {from_name} -> {to_name}".encode(),
payload_schema_id="type.yutha.dev/v1/HandoffAudit",
tags=[
"openai_agents_handoff",
f"handoff_from:{from_name}",
f"handoff_to:{to_name}",
],
)
Two details worth internalizing.
The peer registry is what makes attribution real. Each
YuthaOpenAIAgent constructs its hooks lazily on first run; you
register the rest of the swarm against those hooks before
kicking off a Runner.run. The registry maps
agents.Agent.name (a string) to a YuthaOpenAIAgent wrapper:
peer_registry = {
"ResearchFactChecker": wrappers["fact_checker"],
"ResearchEditor": wrappers["editor"],
"ResearchResearcher": wrappers["researcher"],
}
for wrapper in wrappers.values():
wrapper._get_hooks().register_peers(peer_registry)
With the registry populated, every handoff A → B produces an
envelope signed by A's key, addressed to B's inbox, tagged with
both names. Without it, the bridge falls back to a self-loop —
still produces envelope.send + envelope.deliver receipts,
but they go from and to the owning wrapper, which is less
useful for reconstructing the actual collaboration chain.
Source-wrapper lookup is what makes multi-hop chains correct.
A Runner.run started on agent A that transitions A → B → C
calls on_handoff twice — once with from_agent=A and once
with from_agent=B. Without the registry.get(from_name, ...)
lookup, both envelopes would be attributed to whichever wrapper
owns the hooks (A), which is wrong for the second hop. With the
lookup, the second envelope is correctly emitted from B's
identity. This is what the hooks.py comment calls out
explicitly when it says "the substrate audit log captures every
inter-agent transfer the LLM-driven Runner performs" — without
the source-wrapper lookup, attribution would silently degrade
on every chain longer than a single hop.
The contract: 1:1 Agent ↔ Yutha identity¶
Each agents.Agent in a multi-agent flow gets its own Yutha
passport, signing key, agent.register receipt, and dispatch
loop. Handoffs cross the Yutha wire as signed envelopes between
two distinct agent_id values. Compared to running an OpenAI
Agents flow under a single Yutha identity, this trades a small
amount of setup for substantially better forensics — the audit
log answers "which agent handed off to which, when, and what
prompted it" without you instrumenting anything yourself.
You can opt out per-wrapper by passing
emit_handoff_envelopes=False, which keeps the hooks attached
but reduces them to a stdout log. Useful for unit tests that
shouldn't have substrate side effects.
Step 3 — Gating function_tool callables with capabilities¶
When a tool performs something sensitive (publishing a brief, issuing a refund, calling a payment API, sending PII), you want a server-side gate rather than a client-side check the LLM could bypass by editing the function body. That's what capabilities are for.
A capability is a signed grant scoped to a specific action. The
yutha.openai_agents.capability_required wrapper modifies a
callable so that, during its execution:
- The held cap's scope is validated against the declared
action_kind(a mismatch raisesCapabilityDeniedimmediately at decoration time — catches the "I wrapped this tool with the wrong action_kind for the cap I hold" error before the tool runs once). - The cap's content-address is threaded into a context-local
variable that
YuthaOpenAIAgent.sendreads, so any send made during the tool's execution picks up thecap_idautomatically. On a server-side cap-check deny (revoked, expired, out-of-scope, subject quarantined) the send call raisesCapabilityDenied.
The contextvar is the same ACTIVE_CAPABILITY_ID the LangGraph
and CrewAI adapters use — all three share a single mechanism so
a downstream that mixes frameworks needs only one except
clause for CapabilityDenied.
A typical wrap-and-issue pattern, lifted from the research-crew example:
from agents import function_tool
from yutha.openai_agents import capability_required
# Issue the cap server-side first.
editor_cap = yutha.Capability(
spec_version="1.0.0",
capability_id=secrets.token_bytes(16),
swarm_id=swarm_id,
issuer=yutha.Issuer.for_agent(editor_id),
subject=editor_id,
scope=yutha.Scope.for_action("envelope.send"),
valid_from=yutha.Timestamp.now(),
valid_until=yutha.Timestamp(
wall_clock="2099-01-01T00:00:00Z", monotonic_ns=2**62
),
)
cap_id, _ = await editor_wrapper.client.capability.issue(editor_cap)
# Wrap the callable. capability_required sets the contextvar
# for the duration of every invocation; function_tool exposes
# it to the LLM as a callable tool with a JSON schema.
@capability_required(editor_cap, action_kind="envelope.send")
async def publish_brief(content: str, cited: bool) -> yutha.Hash:
tags = ["claim_published"]
if cited:
tags.append("verified_citations")
return await editor_wrapper.send(
recipient=yutha.Recipient.for_agent(publisher_id),
performative=yutha.Performative.INFORM,
payload=content.encode("utf-8"),
payload_schema_id="type.yutha.dev/v1/Text",
tags=tags,
)
# Attach to the agent's tools list — the LLM can now call it.
editor_agent.tools = [function_tool(publish_brief)]
When the LLM decides to call publish_brief, OpenAI Agents
invokes the wrapped callable. capability_required sets the
contextvar; the body's await editor_wrapper.send(...) reads
it; the resulting gRPC Send request carries the cap_id; the
server runs the RFC 0007 cap-check and emits the load-bearing
capability.check.{pass,deny} receipt.
Composition order is flexible. Both
@function_tool / @capability_required (function_tool
outermost) and @capability_required / @function_tool
(capability_required outermost) work; the wrapper detects which
kind of target it received and patches the right hook. The
research-crew demo uses the canonical form — decorate the plain
async function with capability_required, then pass it through
function_tool at attach time — because the gate is the first
thing the reader sees on the source line.
No sync/async bridging hassle. Because OpenAI Agents'
function_tool callables are async-native, there's no
worker-thread bridging like the CrewAI adapter requires for
sync tool bodies. The contextvar set by capability_required
propagates naturally through await editor_wrapper.send(...)
inside the same event loop.
Step 4 — Reading the audit log¶
The Yutha control plane writes a signed receipt for every substrate-level action. To query them from an OpenAI Agents agent:
async with YuthaOpenAIAgent.connect(...) as agent:
# Most recent 20 capability.check.pass receipts.
page = await agent.client.receipt.query_by_action_kind(
"capability.check.pass", limit=20
)
for receipt in page.receipts:
print(receipt.action_kind, receipt.actor)
The same query works against any of the canonical action_kinds
— see /spec/receipt/canonical-actions.md
for the full list. Useful kinds for an OpenAI Agents
integration:
envelope.send/envelope.deliver— every signed send, including the handoff-audit envelopes the bridge emits. Filter those bytags(every handoff envelope carriesopenai_agents_handoffplushandoff_from:<name>andhandoff_to:<name>) to reconstruct the full agent collaboration chain that a givenRunner.runtraversed.capability.check.{pass,deny}— every cap gate fired by the RFC 0007 server-side check. One perfunction_toolinvocation whose body emits a send.constitution.evaluate.{pass,deny}— every Cedar+ evaluation that ran against an envelope (if you've activated a constitution; see the operator guide).agent.register/agent.revoke— admission lifecycle.
The handoff-audit tag scheme is the load-bearing piece for
OpenAI Agents specifically. Filter on openai_agents_handoff
and you've got every inter-agent transition the swarm has ever
performed, with full sender + recipient attribution and
content-addressed signatures. The audit log is the source of
truth for "what did the multi-agent flow actually do" without
you instrumenting the framework yourself.
Step 5 — The worked example¶
examples/research_crew.py
ties everything together: three OpenAI Agents agents
(researcher → fact_checker → editor) registering into a fresh
swarm, an editor whose publish_brief function tool is
cap-gated, a citation-enforcing constitution that forbids
claim_published envelopes lacking the verified_citations
tag, two bypass attempts that trip the four-stage enforcement
chain (detect → coach → quarantine → evict), and a split
audit-delta assertion to keep the substrate-correctness signal
crisp despite LLM nondeterminism in the handoff phase.
The applied walkthrough at Research crew with citation enforcement covers the example end-to-end — the cast, the constitution, the bridge wiring, the bypass-and-chain pattern, and why the pre→mid / mid→after split exists. Read it after this page if you want to see all the pieces work together against a real control plane.
Where to go from here¶
- Layer a constitution. Use the
operator quickstart to author and
activate a Cedar+ constitution; every send through an OpenAI
Agents agent will then be evaluated against it, with denies
surfacing as
yutha.ConstitutionDenied. The research-crew example is the reference for what an enforcement-active OpenAI Agents flow looks like end-to-end. - Custom LLMs. OpenAI Agents supports model switching via
its
model=parameter on eachAgent(and via LiteLLM for cross-provider configs). Yutha is LLM-agnostic — switch models freely; the substrate semantics don't change. - Hybrid swarms. Nothing prevents an OpenAI Agents agent, a
LangGraph agent, a CrewAI agent, and a Microsoft Agent
Framework agent from sharing the same swarm. They register
the same way, sign with the same Ed25519 algorithm, and
produce the same receipts. The audit log records the
frameworkfield per passport, so you can attribute behavior to the right framework after the fact. A mixed-framework swarm demo is on the roadmap as a later capstone. - Spec reference. The OpenAI Agents adapter is a thin
wrapper around primitives defined in
/spec/— passports, envelopes, capabilities, receipts. If you want to understand exactly what the bridge puts on the wire when it emits a handoff-audit envelope, that's where to look.