Open the console

SDK

Runtime

Starting runs and reading what they say. Every method here wraps the invocations API, and the ones that wait for a reply open the event stream for you, in the right order.

Say one thing: run

run() is the one-shot: one message to one agent, and the answer back. It works on the synchronous Elan client too, because it does all of its socket work inside the call.

Request
from elan_sdk import Elan

elan = Elan.from_env()

reply = elan.runtime.run(
    "hotel-concierge",
    "Is late checkout available?",
    project="salmon",
    session_id="sess-ada-1",
)
if reply.ok:
    print(reply.text)
elif reply.pause is not None:
    print("waiting on", reply.pause.kind, "-", reply.pause.message)
elif reply.timed_out:
    print("no terminal event yet; read it back later with runs.get()")
else:
    print("ended with", reply.terminal, reply.error)

What comes back is a Reply. The TypeScript client returns the same fields and reads its derived facts through functions — replyOk, saidNothing, timedOut, stepsOf — where Python has properties.

FieldWhat it holds
textEvery content event's words, with the parts joined. A reply often arrives in several parts.
terminalfinished, error, paused, or empty when the wait ran out.
errorWhat the error event said, when there was one.
pauseWhat the run is waiting for: invocation_id, interrupt_id, kind and message. Empty unless it paused.
eventsEvery event of this invocation, in arrival order.
okIt finished.
said_nothingIt ended and never spoke. Not a timeout — the agent chose not to.
timed_outNo terminal event arrived in time. The run may still be going.
stepsOne entry per workflow node, keyed on the node's path, so a replayed node is not counted twice.

Neither silence raises. The timeout covers the whole run, not each event — a long tool call is a legitimate pause in the stream — and defaults to five minutes, because a cold start can take more than one.

Several turns: conversation

A conversation keeps one session across turns and sends them one at a time. That matters more than it looks: two invocations on one session at once make all but one fail, after the model has been called and paid for.

Request
import asyncio

from elan_sdk import AsyncElan


async def main() -> None:
    async with AsyncElan.from_env() as elan:
        async with elan.runtime.conversation("hotel-concierge",
                                             project="salmon") as chat:
            await chat.say("I'm in room 412.")
            reply = await chat.say("Is late checkout available?")
            print(chat.session_id, reply.text)


asyncio.run(main())
MethodPythonTypeScript
say(text, …)Takes inputs, timeout, state_delta, run_config. Returns a Reply, over the conversation's one socket.Takes inputs, timeoutMs, stateDelta, runConfig. Returns a Reply; each turn is a run() with its own socket.
approve(invocation_id, …)Resumes a paused workflow with payload and note, and returns the resumed run's Reply.Resumes it and returns Accepted. It does not wait for the reply.
reject(invocation_id, …)The same with approved=False. A rejection also resumes: the graph records an outcome and carries on.As approve, with approved: false.
session_idThe session every turn uses: yours, or sdk-<12 hex>.sessionId, likewise.

Your own event handling: invoke and watching

run() is these two together. Use them directly when you want every event as it happens, or a socket that outlives one run.

Request
import asyncio

from elan_sdk import AsyncElan


async def main() -> None:
    async with AsyncElan.from_env() as elan:
        # The socket is open and subscribed BEFORE the invoke inside the block.
        async with elan.runtime.watching("sess-ada-1") as stream:
            started = await elan.runtime.invoke(
                "hotel-concierge", "Is late checkout available?",
                session_id="sess-ada-1", project="salmon")
            async for event in stream.events(invocation_id=started.invocation_id):
                print(event.kind, event.text)
            # The loop ends at finished, error, or the pause that names an
            # interrupt. Use stream.collect() for a Reply instead.


asyncio.run(main())

Why you subscribe first

An invocation is published to a queue, not answered by an endpoint. The platform discards events published while nothing is subscribed, and your subscription is created when your first socket opens — so a client that invokes and then connects loses everything in between, which on a warm agent is the whole reply. watching() is shaped so that you invoke inside it, and it waits for the server's subscribed acknowledgement rather than assuming it.

Reading the stream

  • Python: stream.events(*, invocation_id="", session_id="", kinds=None) is an async iterator. Its arguments are keyword-only filters. Given an invocation id it stops at that run's end; without one it yields until the socket closes. Control frames never reach you, and a frame this version cannot parse is skipped.
  • Python: stream.collect(invocation_id, *, session_id="", timeout=None) gathers one run into a Reply. None waits for ever. subscribe() and unsubscribe() add and drop other sessions on the same socket.
  • TypeScript: stream.subscribe(sessionId, handler) calls your handler with each of the session's events and returns the unsubscribe; stream.collect(idOrPromise, { timeoutMs?, sessionId? }) resolves to a Reply. Passing the invoke's promise rather than the id is what lets the collector exist before the 202 arrives.

Running for one of your users

invoke(end_user_id="ada") makes the run Ada's: her wallet pays, her autonomy policy applies, a confirmation goes to her — and the events are addressed to her, not to your socket. It needs a tenant admin credential. That is why run(), watching() and conversation() take no end user: they would wait out their timeout on a run that worked. The Python client refuses end_user_id with EndUserEventsElsewhere while one of its own sockets is open; the TypeScript client does not check, so the same mistake there is a silent wait. Mint Ada's browser a ticket with tickets.mint() and let it watch — see TypeScript and React.

State on a turn, and bounding it

run(), invoke() and say() take two of ADK's own run options.

Request
reply = elan.runtime.run(
    "hotel-concierge", "Is late checkout available?",
    project="salmon", session_id="sess-ada-1",
    state_delta={"room": "412"},
    run_config={"max_llm_calls": 5,
                "custom_metadata": {"request_id": "req-81c2"}},
)
  • state_delta writes conversation keys on this turn's own message. Keys starting app:, user:, temp: or agentkit: are refused with a BadRequest naming the key, because they would reach other users or conversations, or would not be kept. At most 64 KiB.
  • run_config takes max_llm_calls and custom_metadata, and refuses anything else by name. max_llm_calls can only lower the agent's own limit.
  • Both are sent only when set, so an ordinary message is unchanged. An agent deployed before these options existed has to be redeployed once before it accepts either.

Inputs for a workflow

A workflow declares its launch inputs in its manifest, and you pass them as inputs. The text is still required and may not be empty — make it a readable one-liner, because it is what lists of runs show. The API passes inputs through unchecked; the runtime validates them against the declaration, so a wrong one arrives as an error event naming the field rather than as a refused request. Building a workflow covers declaring them.

Request
reply = elan.runtime.run(
    "event-planner", "Plan the team offsite",
    project="salmon",
    inputs={"city": "Lisbon", "headcount": 12},
)

What a paused run looks like

A run that stops to wait emits two paused events and no finished at all. The pause is where the run stopped; the answer arrives later as a new message. Only the second paused carries interrupt_id, interrupt_kind and message, and other events can arrive between the two, so neither “the first” nor “the last” is the rule — the presence of interrupt_kind is. event.terminal in Python (also ends_the_run in elan_sdk.models.events, which takes a raw frame too) and endsTheRun(event) in TypeScript spell it, and every method here that waits for a run to end already uses it: a paused run comes back with terminal set to paused and pause filled in.

interrupt_kindWaiting forAnswered with
inputA workflow node asked a person for something.runtime.resume(), or a conversation's approve(). The payload becomes the node's output.
confirmationA tool needs the conversation's person to approve it.actions.decide_batch(). The interrupt id is the batch id.
approvalA tool needs somebody outside the conversation, with a form.approvals.decide().
toolA long-running tool handed work to an outside system.tools.submit() or tools.fail().

One turn can raise more than one kind; the event then names one of them, so treat interrupt_kind as a hint for your copy and the pending actions as the truth. Approvals and autonomy covers the last three. Answering the first looks like this:

Request
import asyncio

from elan_sdk import AsyncElan


async def main() -> None:
    async with AsyncElan.from_env() as elan:
        async with elan.runtime.conversation("event-planner",
                                             project="salmon") as chat:
            reply = await chat.say("Plan the team offsite",
                                   inputs={"city": "Lisbon", "headcount": 12})
            while reply.pause is not None and reply.pause.kind == "input":
                print(reply.pause.message)      # the node's own prompt
                reply = await chat.approve(reply.invocation_id,
                                           payload={"cost_centre": "CC-3317"})
            print(reply.terminal, reply.text)


asyncio.run(main())
  • A resume can raise PaymentRequired where the original run did not. A pause settles what it used and holds nothing while it waits, so the resumed turn reserves again on its own merits.
  • A resumed workflow replays what ran before the pause without running it again. Those events arrive with fresh ids and the same node path, which is why steps is keyed on the path.
  • runs.waiting() lists every paused run, whatever it is waiting for. The action inbox alone never shows an input pause, because one creates no action.

Long-running tools

A tool that hands work to something outside the platform — a batch job, a partner API, a person in another system — returns nothing, and the run pauses with interrupt_kind tool until your backend answers it. There are no webhooks: you poll tools.pending(), then deliver a result with tools.submit() or a failure with tools.fail(), before the deadline the agent declared. The result becomes the tool's return value. The details, including who may answer, are on Approvals and autonomy.

When the socket drops

Nothing buffers events for a socket that is not open. The stream is the live path and never the durable one: your subscription is deleted about two minutes after your last socket closes, and there is no replay. What already happened is read back from the record.

Request
run = elan.runs.get(invocation_id)      # running, paused, finished or error
reply = run.to_reply()                  # folded exactly as a watched run is
print(run.status, reply.text)

for waiting in elan.runs.waiting(project="salmon"):
    print(waiting.invocation_id, waiting.pause)
  • In Python a dropped socket ends the read quietly. events() stops and collect() returns what it had, with timed_out true. The stream does not reconnect; open a new one.
  • In TypeScript the stream reconnects with backoff (one second, doubling, up to fifteen) and subscribes its sessions again, but anything published in the gap is gone. A refused credential — close code 1008 — is never retried.
  • runs.get() reads one run from its first event, including one still running or paused, and to_reply() (toReply) folds it exactly as a watched run is folded. To reopen a whole conversation, read its events instead.

Methods

runtime.run(target, text, *, project=None, session_id="", inputs=None, timeout=None, state_delta=None, run_config=None)

A Reply, once the run ends, pauses or the wait runs out.

TypeScript: runtime.run(target, text, { project?, sessionId?, inputs?, timeoutMs?, stateDelta?, runConfig? })

Opens a socket, subscribes and waits for the acknowledgement, invokes, keeps only this invocation's events, joins the parts, stops at the end and closes the socket. Without a session id it starts a fresh one, sdk-<12 hex>. It takes no end user, deliberately: see invoke.

runtime.conversation(target, *, project=None, session_id="")

A Conversation: several turns on one session, one at a time.

TypeScript: runtime.conversation(target, { project?, sessionId? })

In Python it is an async context manager on AsyncElan and holds one socket for every turn. Turns are serialized because two concurrent invocations on one session make all but one fail.

runtime.invoke(target, text, *, session_id, project=None, inputs=None, end_user_id="", state_delta=None, run_config=None)

Accepted: invocation_id and message_id. The 202 — nothing has run.

TypeScript: runtime.invoke(target, text, { sessionId, project?, inputs?, endUserId?, stateDelta?, runConfig? })

The reply only ever arrives on the event stream, so invoke inside watching() unless the events are addressed to somebody else.

runtime.resume(invocation_id, *, approved, note="", payload=None)

Accepted, for the same invocation id.

TypeScript: runtime.resume(invocationId, { approved, note?, payload? })

Answers a workflow that paused for input. Only the run's requester or an admin may; a run that is not paused is a Conflict.

runtime.watching(session_id)

An async context manager yielding an AsyncEventStream; in TypeScript, whatever your callback returns.

TypeScript: runtime.watching(sessionId, async (stream) => …, { timeoutMs? })

Opens and authenticates the socket, subscribes the session and waits for the server to say so, then runs your code. The socket closes on every path, including when your code raises.

The routes underneath are in the invocations reference; how a session and a run relate is in Sessions and runs.

Reading this as a model? llms.txt indexes every page, and each page has a “Copy for AI” control.