|
| 1 | +# Email Vendor Leases |
| 2 | + |
| 3 | +This recipe shows how to attach vendor-specific lease fields to a job using |
| 4 | +`x-*` extensions (spec [§15](https://arcp.dev/spec/v1.1#section-15)) alongside |
| 5 | +the standard cost budget (spec [§9](https://arcp.dev/spec/v1.1#section-9)) to |
| 6 | +enforce per-job email-sending limits. |
| 7 | + |
| 8 | +## Use-case |
| 9 | + |
| 10 | +Your agent sends transactional emails through a third-party provider (e.g., |
| 11 | +SendGrid, Postmark). You want to: |
| 12 | + |
| 13 | +* Cap the dollar cost of a single job. |
| 14 | +* Cap the number of email messages the job may send. |
| 15 | +* Expose the remaining quota back to the caller through structured events. |
| 16 | + |
| 17 | +The cost budget is a first-class ARCP concept; the email quota is represented |
| 18 | +as a vendor extension field (`x-email-max-messages`) that your runtime |
| 19 | +evaluates and your agent reads from `ctx.lease`. |
| 20 | + |
| 21 | +## Server |
| 22 | + |
| 23 | +```python |
| 24 | +import asyncio |
| 25 | +from decimal import Decimal |
| 26 | +from arcp import ARCPRuntime, JobContext |
| 27 | +from arcp.auth import StaticBearerVerifier |
| 28 | +from arcp.transport import pair_memory_transports |
| 29 | + |
| 30 | +MAX_EMAILS = 50 |
| 31 | + |
| 32 | + |
| 33 | +async def email_agent(ctx: JobContext) -> None: |
| 34 | + # Read the vendor extension from the negotiated lease. |
| 35 | + raw = ctx.lease.extensions.get("x-email-max-messages") |
| 36 | + email_budget = int(raw) if raw is not None else MAX_EMAILS |
| 37 | + sent = 0 |
| 38 | + |
| 39 | + async for item in ctx.input_stream(): |
| 40 | + if sent >= email_budget: |
| 41 | + await ctx.emit_event( |
| 42 | + "vendor.email.quota_exceeded", |
| 43 | + {"sent": sent, "limit": email_budget}, |
| 44 | + ) |
| 45 | + await ctx.cancel("email quota reached") |
| 46 | + return |
| 47 | + |
| 48 | + # Simulate sending an email. |
| 49 | + recipient = item.get("to", "") |
| 50 | + await _send_email(recipient, item) |
| 51 | + sent += 1 |
| 52 | + await ctx.emit_event( |
| 53 | + "vendor.email.sent", |
| 54 | + { |
| 55 | + "to": recipient, |
| 56 | + "sent": sent, |
| 57 | + "remaining": email_budget - sent, |
| 58 | + }, |
| 59 | + ) |
| 60 | + |
| 61 | + await ctx.emit_event("vendor.email.summary", {"total_sent": sent}) |
| 62 | + |
| 63 | + |
| 64 | +async def _send_email(to: str, payload: dict) -> None: |
| 65 | + """Placeholder — swap in your actual provider SDK call.""" |
| 66 | + await asyncio.sleep(0.01) |
| 67 | + |
| 68 | + |
| 69 | +server_transport, client_transport = pair_memory_transports() |
| 70 | + |
| 71 | +runtime = ARCPRuntime( |
| 72 | + transport=server_transport, |
| 73 | + auth=StaticBearerVerifier("secret"), |
| 74 | +) |
| 75 | +runtime.register_agent("email", email_agent) |
| 76 | +``` |
| 77 | + |
| 78 | +## Client — submitting with vendor lease extensions |
| 79 | + |
| 80 | +```python |
| 81 | +from arcp import ARCPClient |
| 82 | +from arcp.models import CostBudget, Lease |
| 83 | + |
| 84 | + |
| 85 | +async def main() -> None: |
| 86 | + async with ARCPClient(client_transport, token="secret") as client: |
| 87 | + handle = await client.submit( |
| 88 | + agent="email", |
| 89 | + input=[ |
| 90 | + {"to": "alice@example.com", "subject": "Hello", "body": "Hi!"}, |
| 91 | + {"to": "bob@example.com", "subject": "Hello", "body": "Hi!"}, |
| 92 | + ], |
| 93 | + lease=Lease( |
| 94 | + cost_budget=CostBudget(usd=Decimal("0.50")), |
| 95 | + extensions={ |
| 96 | + # Vendor field: cap this job at 10 emails. |
| 97 | + "x-email-max-messages": "10", |
| 98 | + }, |
| 99 | + ), |
| 100 | + ) |
| 101 | + |
| 102 | + async for event in handle.events(): |
| 103 | + kind = event.kind |
| 104 | + if kind == "vendor.email.sent": |
| 105 | + print( |
| 106 | + f"Sent to {event.data['to']} " |
| 107 | + f"({event.data['remaining']} remaining)" |
| 108 | + ) |
| 109 | + elif kind == "vendor.email.quota_exceeded": |
| 110 | + print(f"Quota exceeded after {event.data['sent']} emails") |
| 111 | + elif kind == "vendor.email.summary": |
| 112 | + print(f"Done — total sent: {event.data['total_sent']}") |
| 113 | + |
| 114 | + await handle.done |
| 115 | + |
| 116 | + |
| 117 | +asyncio.run(main()) |
| 118 | +``` |
| 119 | + |
| 120 | +## How it works |
| 121 | + |
| 122 | +| Layer | Mechanism | |
| 123 | +|---|---| |
| 124 | +| Cost budget | `Lease.cost_budget` — enforced natively by the runtime (spec §9) | |
| 125 | +| Email quota | `Lease.extensions["x-email-max-messages"]` — enforced by the agent | |
| 126 | +| Quota events | `vendor.email.*` — structured events consumed by the caller | |
| 127 | + |
| 128 | +`x-*` extension fields are passed through unchanged; the runtime does not |
| 129 | +interpret them. The naming convention `x-<vendor>-<field>` avoids collisions |
| 130 | +with future ARCP spec additions (spec §15.2). |
| 131 | + |
| 132 | +## Related |
| 133 | + |
| 134 | +- [Leases guide](../guides/leases.md) |
| 135 | +- [Vendor extensions guide](../guides/vendor-extensions.md) |
| 136 | +- [Cost budget recipe](cost-budget.md) |
| 137 | +- [Lease violation recipe](lease-violation.md) |
0 commit comments