Skip to content

Pod Events

An agent served by latent serve can handle events inside its own pod. A timer the agent armed fires, or a caller posts an event to Studio. Studio's worker then calls the pod that serves the conversation. The handler decides what happens.

Declare a handler

Mark an async agent method with @event:

from latent.agents import BaseAgent, agent
from latent.serve.events import EventContext, EventResult, event


@agent("support")
class SupportAgent(BaseAgent):
    studio_capabilities = ("timebox.v1",)

    @event("idle_nudge", source="scheduled")
    async def idle_nudge(self, ctx: EventContext) -> EventResult:
        """The customer went quiet: nudge them once."""
        return EventResult.send("Are you still there?")
  • source="scheduled" handles a timebox firing. source="external" handles an event a caller posted to Studio.
  • Names match ^[a-z][a-z0-9_-]{0,63}$. One (source, name) has one handler.
  • The manifest lists every declared event and adds the events.v1 capability. Add timebox.v1 and scratchpad.v1 to studio_capabilities when the agent uses them.

Decide

Return an EventResult:

Result Effect
EventResult.noop() Nothing happens.
EventResult.send(text) Studio sends text to the conversation as the assistant.
EventResult.finish(text) Studio sends text and finishes the conversation.
EventResult.run_agent(prompt) Studio runs an agent turn with prompt as its input.

EventContext carries conversation_id, thread_id, endpoint_id, channel, event_name, source, and, when present, note, payload and event_id.

Timeboxes and scratchpad

ctx.studio is a StudioEvents for the conversation. It calls Studio's pod routes with the pod's project key (AGENT_STUDIO_API_KEY against AGENT_STUDIO_URL); the pod never touches Studio's database.

nonce = await ctx.studio.arm("idle_nudge", seconds=600, note="first nudge")
await ctx.studio.cancel("idle_nudge")
state = await ctx.studio.peek("idle_nudge")      # None if never armed

await ctx.studio.scratch_set("nudged", True)
nudged = await ctx.studio.scratch_get("nudged")  # None if absent
await ctx.studio.scratch_remove("nudged")

A value stored as a secret comes back as a Secret (latent.serve.events): it renders <redacted:key> in str, repr and f-strings, so returning it from a tool never puts the token in the model's context or the trace. reveal() returns the plaintext.

await ctx.studio.scratch_set("crm_token", token, secret=True)
token = await ctx.studio.scratch_get("crm_token")  # Secret
headers = {"Authorization": f"Bearer {token.reveal()}"}

scratch_set without secret= keeps the flag the key already has; pass secret=False to store a plain value over a secret one. arm takes an idempotency_key: a retry with the same key returns the first arm's nonce and starts no second timer.

The routes are POST /api/serving/timeboxes/arm, POST /api/serving/timeboxes/cancel, GET /api/serving/timeboxes/{conversation}/{name} and GET|PUT|DELETE /api/serving/scratchpad/{conversation}/{key}. Every failure raises: a 401 or 403 raises StudioAuthError, other errors raise httpx.HTTPStatusError. A handler that armed nothing never believes it did.

The key must be unscoped or scoped to this agent. An extension-scoped key is refused.

How Studio calls the pod

Studio's worker sends POST /v1/events/{source}/{name}, signed with the version's HMAC key like every other pod call, with an EventRequest body and an Idempotency-Key:

  • The key is the timer's firing nonce, else the external event_id.
  • Studio calls the pod only when the conversation's served version declares the event. A conversation with no served version keeps Studio's in-worker handler.
  • Studio records each firing durably and retries a failed call with the same version and key. The pod answers a repeated key with its recorded answer.

The pod answers:

Status When
200 The handler's EventDecision.
401 Unsigned, wrongly signed or replayed.
404 No handler for that source and name.
422 The body is not a valid EventRequest, or it names another event.
500 The handler raised or did not return an EventResult.
504 The handler took longer than 20 s.

latent conformance checks the 401, 404 and 422 answers of every declared event.

See also