Examples
Runnable recipes, roughly in order of ambition. All are asyncio code; wrap each
main() in asyncio.run(main()). The first mirrors the repo's
examples/hello_world.py.
Zero config: a pair that talks
amp_pair() from fg_amp.testing returns two connected in-process nodes — no relay,
no network, no optional extras. With an on_session responder (callbacks run as their
own tasks, so receiving inside one is safe), a ping-pong is the whole program:
import asyncio
from fg_amp.testing import amp_pair
async def main():
async def respond(session): # runs as its own task
message = await session.receive()
await session.send_text(f"pong ({message.payload.content})")
a, b = await amp_pair(on_session=respond)
session = await a.initiate(b.card, purpose="hello")
await session.send_text("ping")
print((await session.receive(timeout=1)).payload.content) # pong (ping)
asyncio.run(main())
The same helper carries your integration tests — both nodes are open-policy by
default, and async with node: closes sessions and detaches the transport on exit:
from fg_amp.testing import amp_pair
async def test_my_agent_talks_to_a_peer():
mine, peer = await amp_pair()
async with mine:
session = await mine.initiate(peer.card, purpose="test")
await session.send_text("ping")
echo = peer.sessions[session.session_id] # peer's side of the same session
Manual wiring: two nodes, one session
The parts amp_pair assembles — explicit identities, policy, and a shared
InMemoryTransport:
import asyncio
from fg_amp import AgentIdentity, AmpNode, ContactPolicy, InMemoryTransport
async def main():
inbound = []
async def on_session(session):
inbound.append(session)
alice = AmpNode(identity=AgentIdentity.generate("alice"))
bob = AmpNode(identity=AgentIdentity.generate("bob"),
policy=ContactPolicy.open(), on_session=on_session)
transport = InMemoryTransport()
alice.attach(transport)
bob.attach(transport)
session = await alice.initiate(bob.card, purpose="price negotiation")
await session.send_text("Offering 100 units at $4.20 — interested?")
message = await inbound[0].receive(timeout=1)
print(message.sender, "→", message.payload.content)
await session.close()
asyncio.run(main())
Owners, scopes, and a credentialed policy
An owner is the cold root of trust; agents get scoped authority through delegation
chains. A credentialed policy that pins trusted_issuers turns scopes into a real
gate (without that pin, scopes are advisory — a peer can self-sign any chain).
from fg_amp import AmpNode, ContactPolicy, Delegation, OwnerIdentity
acme = OwnerIdentity.generate("acme-corp") # cold root of trust
buyer = AmpNode(identity=acme.create_agent("buyer", {"converse", "negotiate"}))
# The seller only accepts peers whose "negotiate" scope was granted by acme
seller_policy = ContactPolicy.credentialed({"negotiate"}, trusted_issuers={acme.address})
The verified authority surfaces on the session — check the chain, never the messages:
assert session.peer_owner == acme.address
session.require_scope("negotiate", owner=acme.address) # raises SessionError if not
A chain can also be built explicitly and attached to an identity:
chain = Delegation.grant(principal.keys, principal.address, buyer_id.address,
scopes={"converse", "negotiate"}, ttl_seconds=3600)
node = AmpNode(identity=buyer_id.with_delegation(chain))
Groups
A group is a full mesh of pairwise E2E sessions with a founder-signed roster.
group = await analyst.create_group([trader.card, broker.card], purpose="deal room")
await group.send_text("proposal: 500 units at $3.90") # E2E to every member
# On a member node (register via on_group=collector on the AmpNode):
msg = await member_group.receive(timeout=1) # GroupMessage
print(msg.sender, msg.payload.content)
await trader_group.leave() # others receive a
event = await member_group.receive(timeout=1) # GroupEvent
print(event.kind, event.member) # "left", trader address
The founder can add_member(card) / remove_member(address); each roster change
bumps group.epoch.
Relay + discovery
Run amp-relay --port 8404 on a host, or drive create_relay_app() in-process for
tests. Agents register cards, long-poll mailboxes, and discover each other by address
— the relay only ever sees ciphertext.
The one-liner path — AmpNode.create builds the right transport from the URL scheme
(http(s):// polling, ws(s):// WebSocket push with HTTP fallback) and connects.
It defaults to a closed policy, so pass policy= to be reachable:
from fg_amp import AgentIdentity, AmpNode, ContactPolicy
async with await AmpNode.create(
identity, relay="https://relay.example", policy=ContactPolicy.open()
) as node:
session = await node.initiate(peer_card, purpose="hello", timeout=10)
Hold the transport explicitly when you also need discovery (resolve_card) —
AmpNode.create(identity, transport=relay) accepts a ready-made instance:
from fg_amp import RelayTransport
relay = RelayTransport("https://relay.example")
await relay.connect(node) # register card, start polling
peer_card = await relay.resolve_card("amp:key:…") # signed-card directory
session = await node.initiate(peer_card, purpose="hello", timeout=10)
In-process variant (as in examples/networked_relay.py): build
app = create_relay_app(), drive it with an httpx ASGI client injected as
http_call=, then RelayTransport("http://relay.local", http_call=http_call) and
await relay_b.connect(bob, poll_interval=0.05).
Wake receiver + non-blocking initiate
For agents that sleep between messages. The recipient advertises a wake URL in its card; the relay POSTs a content-free ping when mail arrives and nobody is polling.
from fg_amp import AmpNode, RelayTransport, WakeNotifier, WakePolicy, WakeReceiver, create_relay_app
# Relay side: enable wake (SSRF-guarded, debounced, non-amplifying)
app = create_relay_app(waker=WakeNotifier(policy=WakePolicy()))
# Recipient side: the wake listener boots the node and pulls
async def on_wake():
node = AmpNode(identity=me, on_session=handle)
transport = RelayTransport(relay_url)
await transport.connect(node) # pull drains everything waiting
receiver = WakeReceiver(on_wake, path="/wake")
await receiver.start(host="0.0.0.0", port=8080)
# Initiator side: don't block on a sleeping peer
pending = await node.initiate(peer_card, wait=False)
session = await pending.wait(timeout=None)
The wake is a hint — the poll loop is the source of truth, so a dropped ping costs latency, never correctness.
Persistent session + resume
Persistent sessions survive process restarts. The stored record contains no key material; resume re-authenticates, re-verifies the delegation chain fresh, and mints a fresh session key.
from fg_amp import AmpNode, FileSessionStore, SessionMode
node = AmpNode(identity=me, session_store=FileSessionStore("sessions/"))
session = await node.initiate(peer_card, mode=SessionMode.PERSISTENT)
record = node.persist_session(session) # SessionRecord — safe to store
# ... process restarts ...
session = await node.resume(record.session_id, timeout=30)
MCP bridge
Carry MCP JSON-RPC over an encrypted AMP session (amp.mcp/1). One side exposes a
handler, the other calls through it.
from fg_amp import McpBridge
# Server side: expose an MCP handler over the session
async def handler(request: dict) -> dict:
return await my_mcp_server.dispatch(request)
server_bridge = McpBridge(server_session, handler=handler)
await server_bridge.pump() # dispatch inbound mcp bodies
# Client side: JSON-RPC call, correlated by id
client_bridge = McpBridge(client_session)
result = await client_bridge.call({"jsonrpc": "2.0", "method": "tools/list"},
timeout=30.0)
Multiple bridges layer on one session — each dispatch returns False for other
mcp_session correlators.
Payment lifecycle (x402)
amp.payment/1 carries the x402 objects verbatim; AMP enforces the lifecycle and the
sender's delegation-chain spend caps (per-transaction and cumulative, before encrypt).
from fg_amp import PaymentBody, PaymentKind
# Seller quotes
await seller_session.send_body(PaymentBody(
payment_id="pay-1", kind=PaymentKind.QUOTE,
amount="4.20", asset="USDC", pay_to="0xSELLER",
x402={...}, # x402 payment requirements, verbatim
))
# Buyer authorizes — cap-verified against the buyer's own delegation chain;
# raises SpendRejectedError if over per-tx or cumulative limits
await buyer_session.send_body(PaymentBody(
payment_id="pay-1", kind=PaymentKind.AUTHORIZATION,
amount="4.20", asset="USDC", pay_to="0xSELLER",
x402={...}, # x402 payment payload
))
# Seller settles out-of-band via x402, then reports
await seller_session.send_body(PaymentBody(
payment_id="pay-1", kind=PaymentKind.SETTLED, tx_ref="0xabc…",
amount="4.20", asset="USDC", pay_to="0xSELLER",
))
PaymentTracker refuses illegal transitions (an authorization must reference a known
unexpired quote; the quoting side cannot authorize its own quote; only the quoter
reports settled/failed) with PaymentLifecycleError. session.spend /
session.peer_spend expose the cumulative ledgers.
Transcript verification
Both parties advance an identical SHA-256 hash chain over every envelope. Comparing heads proves an identical, untampered history without exchanging it.
await session.send_text("final terms: 500 units at $3.90")
# ... conversation ends ...
assert session.transcript.head == peer_session.transcript.head
# or against a stored expectation:
assert session.transcript_matches(expected_head)
await session.close("deal reached") # keys burn; the head survives as evidence