Building a Microsoft Agent Framework agent on Yutha¶
A walkthrough for developers and AI engineers who want to put Microsoft Agent Framework (MAF) agents on top of Yutha's coordination substrate. Companion to the LangGraph guide and the CrewAI guide — same substrate, different framework idioms.
By the end you'll have:
- A signed, registered MAF
ChatAgenttalking to a real Yutha control plane. - A MAF agent whose
Agent.run(...)is driven by inbound Yutha envelopes, with each run producing its own audit-log trail. - A MAF async tool callable wrapped with
@capability_requiredso server-side capability gates fire when the callable emits a Yutha envelope. - A query against the audit log showing exactly which MAF agent did what.
What you get for free. Every message is Ed25519-signed by
its sender's MAF agent. Every send, every delivery, every
capability check, every admission/revocation produces a content-
addressed receipt. The 1:1 mapping between MAF Agent instances
and Yutha agents means the audit log records the actual
decision-making agent for each action, not a workflow-level
aggregate.
What you write. Three things: per-agent passports, the MAF
Agent instances themselves, and optional @capability_required
wrappers on sensitive async tool callables. Everything else —
bearer-token minting, signature verification, 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 MAF's
Agent / ChatAgent / agent.run(...) surface and its async-
native tool callable 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 maf extra:
The maf extra pulls in agent-framework-core and
agent-framework-openai — the two narrowed packages the adapter
actually needs — rather than the umbrella agent-framework
distribution. The reason is concrete: the umbrella package
carries a pre-release azure-search-documents>=11.7.0b2
dependency, which would force every installer to add
--prerelease=allow to pip (or --prerelease=if-necessary to
uv) just to satisfy resolution. Narrowing to the two packages
the adapter exercises avoids that footgun. If you want MAF's
other chat clients (Foundry, Azure OpenAI, Anthropic, …) install
them alongside as needed: pip install 'yutha[maf]'
agent-framework-foundry.
Working from a repo clone instead of PyPI? Use an editable
install: cd sdks/python && uv pip install -e '.[dev,maf]'.
A package-name note that catches people once and then never
again: the PyPI distribution is agent-framework (hyphen) but
the importable Python module is agent_framework (underscore).
The Yutha adapter is named yutha.maf rather than
yutha.agent_framework to keep imports short and to avoid any
confusion with the upstream framework's own packaging. So your
imports look like:
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from yutha.maf import YuthaChatAgent, capability_required
3. The same YUTHA_BOOTSTRAP_SEED exported in your dev shell
so derived swarm_ids match.
4. An LLM credential. MAF's Agent constructor requires a
chat client. The simplest default is OpenAI via
OpenAIChatClient, which takes a model= kwarg (e.g.
"gpt-4o-mini") and reads OPENAI_API_KEY from the environment:
MAF also supports Foundry, Azure OpenAI, Anthropic, and several other backends via dedicated chat-client classes — see MAF's docs for switching providers. The Yutha adapter is provider-agnostic; the choice only affects how each MAF agent reasons.
Step 1 — Your first MAF agent on Yutha¶
The smallest useful pattern: mint a passport, connect, register a
MAF Agent under a Yutha identity, send a message to yourself,
receive it on the dispatch loop.
import asyncio
import hashlib
import os
import secrets
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
import yutha
from yutha.maf import YuthaChatAgent
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 MAF 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-maf",
framework="maf",
framework_version="1.0.0",
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 a MAF Agent. The chat client + instructions 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 run() against.
maf_agent = Agent(
client=OpenAIChatClient(model="gpt-4o-mini"),
name="Greeter",
instructions="Acknowledge inbound messages in one short sentence.",
)
# Wrap and connect. The wrapper's lifecycle mirrors
# yutha.langgraph.YuthaAgent and yutha.crewai.YuthaCrewAgent —
# register, async-context start + stop, send to emit envelopes.
async with YuthaChatAgent.connect(
"127.0.0.1:50051",
passport=passport,
signing_key=signing_key,
maf_agent=maf_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 a MAF 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,
# decodes the payload as UTF-8 and passes it as the input
# argument to maf_agent.run(...). Wait a moment so the
# async run has time to fire.
await asyncio.sleep(0.5)
asyncio.run(main())
A few things to note:
- The passport's
frameworkfield is"maf". Useful for audit-log filtering and analytics later on; doesn't affect any wire semantics. Demos that register multiple distinct MAF agents tend to use more specific labels (e.g."maf-devops-alerting","maf-devops-triage") so the audit trail attributes work by role. - The signing key never leaves your process. The Yutha control plane verifies every envelope's signature against the public key on the passport.
YuthaChatAgentis not itself a MAF Agent. It wraps one. The wrapping is deliberate: the wrapper owns the gRPC channel, the registration, the subscribe stream, and the signing key — none of which MAF cares about.
Step 2 — The dispatch loop: envelope → agent.run¶
When YuthaChatAgent.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 nextAgent.run(...)call. The default factory decodesenvelope.payloadas UTF-8 and uses that string directly. - Invokes
await maf_agent.run(input). Tool calls inside that run that go through@capability_required-wrapped callables thread the held cap_id into outbound sends. - Hands the result to your optional on_output callback,
which can inspect what
Agent.runreturned and emit a follow- on envelope viaagent.send(...).
A typical input factory looks like this:
def my_input_factory(agent, envelope, deliver_receipt):
# Parse the inbound payload. Branch on payload_schema_id if
# you support multiple kinds of inbound messages.
if envelope.payload_schema_id == "type.yutha.dev/v1/Ticket":
return (
f"Process this ticket and produce a short customer-"
f"facing acknowledgment:\n{envelope.payload.decode()}"
)
# Skip envelopes we don't know how to handle.
return None
async def on_output(agent, envelope, result):
# `result` is whatever Agent.run returned. Extract the
# customer-facing text and emit it as a follow-on envelope.
await agent.send(
recipient=envelope.recipient,
performative=yutha.Performative.INFORM,
payload=str(result).encode("utf-8"),
in_reply_to=..., # the inbound envelope id
)
Returning None from the input factory skips the inbound
envelope. Use that for filtering — for example, the devops
incident demo's orchestrator drives every Agent.run directly
and passes input_factory=lambda a, e, d: None to make the
dispatch loop a no-op for every wrapped agent (avoids cascade
behavior when the orchestrator is the one driving turns).
A concrete contrast with CrewAI worth calling out: CrewAI tool
bodies are synchronous, so the CrewAI adapter has to bridge sync
→ async via asyncio.run_coroutine_threadsafe whenever a tool
wants to emit a Yutha envelope. MAF's Agent.run and its tool
callables are async-native end-to-end, which means there's no
thread-bridge boilerplate inside the dispatch loop or inside
your tool bodies. Contextvars propagate cleanly inside the same
event loop, which is also what makes the cap-gating decorator in
Step 3 work as-is.
Step 3 — Gating async tool callables with capabilities¶
When a MAF tool callable performs something sensitive (issuing a refund, applying a production schema change, sending PII), you want a server-side gate rather than a client-side check the agent could remove. That's what capabilities are for.
A capability is a signed grant scoped to a specific action. The
yutha.maf.capability_required decorator wraps an async tool
callable so that, during its execution:
- The held cap's scope is validated against the declared
action_kind(a mismatch raisesCapabilityDeniedat decoration time, before the tool can run). - The cap's content-address is threaded into a context-local
variable that
YuthaChatAgent.sendreads, so any send made during the tool's execution picks up the cap_id automatically. On a server-side cap-check deny (revoked, expired, out-of-scope) the send call raisesCapabilityDenied.
from yutha.maf import capability_required
# Issue the capability server-side first.
remediation_cap = yutha.Capability(
spec_version="1.0.0",
capability_id=secrets.token_bytes(16),
swarm_id=swarm_id,
issuer=yutha.Issuer.for_agent(remediation.agent_id),
subject=remediation.agent_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 remediation.client.capability.issue(remediation_cap)
# Wrap the async tool callable.
@capability_required(remediation_cap, action_kind="envelope.send")
async def apply_action(action_description: str, schema_change: bool) -> yutha.Hash:
tags = ["production_action"]
if schema_change:
tags.append("schema_change")
return await remediation.send(
recipient=yutha.Recipient.for_agent(audit_recipient_id),
performative=yutha.Performative.INFORM,
payload=action_description.encode("utf-8"),
tags=tags,
)
# Pass the wrapped callable to a MAF Agent's tools list.
remediation_maf = Agent(
client=OpenAIChatClient(model="gpt-4o-mini"),
name="Remediation",
instructions="Apply production rollbacks when requested.",
tools=[apply_action],
)
When the LLM decides to call apply_action, MAF invokes the
async callable. The decorator sets the contextvar; the body
calls remediation.send(...); the send's gRPC call carries the
cap_id; the server runs the RFC 0007 cap-check and emits the
load-bearing capability.check.{pass,deny} receipt.
The contextvar threading is identical to the LangGraph, CrewAI,
and OpenAI Agents adapters. The win here is that MAF's async-
native tool surface means no thread-bridge boilerplate — the
wrapped callable just awaits remediation.send(...) directly,
the dispatch loop's event loop owns the gRPC channel, and the
contextvar propagates inside the same task tree without any
extra glue.
Step 4 — Reading the audit log¶
The Yutha control plane writes a signed receipt for every substrate-level action. To query them from a MAF agent:
async with YuthaChatAgent.connect(...) as agent:
# Most recent 20 capability.check.pass receipts.
receipts, _next_page = await agent.client.receipt.query_by_action_kind(
"capability.check.pass", limit=20
)
for receipt in 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 a MAF integration:
envelope.send/envelope.deliver— every signed send.capability.check.{pass,deny}— every cap gate fired by the RFC 0007 server-side check.constitution.evaluate.{pass,deny}— every Cedar+ evaluation.agent.register/agent.revoke— admission lifecycle.
Step 5 — The worked example¶
examples/devops_incident.py
ties everything together: five MAF agents
(alerting / triage / remediation / post_mortem /
human_sre) registering into a fresh swarm, a custom
constitution that forbids production schema_change actions
without an sre_countersigned tag, a cap-gated apply_action
callable on the remediation agent, an LLM-driven exploration
phase, and a fully deterministic substrate phase that exercises
the four-stage detect/coach/quarantine/evict enforcement chain.
For a step-by-step walkthrough of what each phase does and how the audit-trail assertion is structured, see the applied devops incident-response example. That page is the place for narrative; this page is the place for adapter mechanics.
Run it:
export OPENAI_API_KEY=...
export YUTHA_BOOTSTRAP_SEED=$(python -c \
'import secrets; print(secrets.token_hex(32))')
# Terminal A — the control plane needs the operator pubkey
# derived from the same seed.
cargo run -p yutha-control-plane -- \
--admission-mode open \
--operator-public-key $(python sdks/python/examples/devops_incident.py --print-operator-pubkey)
# Terminal B (with the same seed exported)
python sdks/python/examples/devops_incident.py
Step 6 — v1 scope vs documented follow-ons¶
The v1 adapter is deliberately the smallest substrate-correct
integration that exercises MAF's Agent surface, and several
MAF-distinctive capabilities are tracked as follow-ons rather
than v1 features. Setting expectations here matters more than
for the other adapters because MAF's value proposition leans
heavily on its workflow and middleware primitives, and the v1
adapter doesn't yet wire those into the substrate. The source
of truth for this list is the
yutha.maf package docstring;
this section reproduces it in walk-through form.
WorkflowBuilder integration. v1 drives agents directly —
the orchestrator calls await alerting.run(incident) and so on.
MAF's graph-based WorkflowBuilder is the natural next step:
compose the runbook as a workflow
(alerting → triage → remediation → post_mortem), where each
workflow edge emits a Yutha envelope so the graph's execution
trace shows up in the audit log alongside the substrate's own
receipts. The adapter primitives already support this; the
demo's orchestrator path would just swap to workflow.run(...).
RequestInfoExecutor and HITL. v1 has a human_sre agent
that gets registered but isn't driven through MAF's formal
human-in-the-loop primitive. Wiring RequestInfoExecutor so the
request + countersign cycle produces approval_required and
countersigned Yutha receipts is the natural HITL upgrade. The
substrate's receipt vocabulary already has slots for these; the
v1 demo just doesn't exercise them because the cap-gating +
constitution-deny path covers the substrate behavior we wanted
to verify first.
AgentMiddleware / FunctionMiddleware as the cap-gating
hook. v1 reuses the contextvar-based @capability_required
decorator the other three adapters use, which works because
MAF's tool invocation is async-native and contextvars propagate
cleanly across await boundaries inside the same event loop. A
future revision could move cap-gating into a
YuthaFunctionMiddleware for tighter MAF middleware-pipeline
integration — both approaches produce identical substrate
behavior, so this is stylistic rather than corrective.
Checkpoint receipts. MAF's checkpoint primitive is distinct from Yutha's receipt log — checkpoints capture workflow state for replay, receipts attest to substrate-level actions. Bridging them would let workflow checkpoint events surface in the audit trail (and conversely, let receipt queries serve as a coarse checkpoint timeline for workflows that don't need full state capture).
OpenTelemetry bridge. MAF emits OTel spans for agent runs, workflow execution, and tool invocations. Bridging those to Yutha telemetry receipts (or in the other direction, building a Yutha OTel exporter so the substrate participates in an operator's existing OTel stack) is useful when an operator already has the observability plumbing and wants the substrate to slot into it.
Frame these as deliberate v1 scoping decisions. The substrate-
correct integration is here today; the MAF-distinctive features
layer on top of the same core YuthaChatAgent wrapper without
requiring it to change shape. The v1 surface is sufficient for
the end-to-end demos that the substrate's audit-trail assertions
care about.
Where to go from here¶
- Layer a constitution. Use the
operator quickstart to author and
activate a Cedar+ constitution; every send through a MAF
agent will then be evaluated against it, with denies
surfacing as
yutha.ConstitutionDenied. - Provider switching. MAF supports Foundry, Azure OpenAI,
Anthropic, and others via dedicated chat clients. Install
alongside the
[maf]extra as needed — e.g.pip install 'yutha[maf]' agent-framework-foundry— and swap theOpenAIChatClientargument on eachAgentfor the appropriate alternative. Yutha is provider-agnostic. - Hybrid swarms. Nothing prevents a MAF agent from sharing
a swarm with LangGraph, CrewAI, or OpenAI Agents agents. 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. The devops example already uses distinctframeworklabels per agent (maf-devops-alerting, etc.) which is the pattern to copy. - Spec reference. The
substrate spec
is the authoritative description of the wire format —
passport, envelope, receipt, capability, topology. The MAF
adapter implements the same wire contract as every other
adapter, so anything you read in
/spec/applies directly.