Skip to main content

Applications in Python

An application can be written in Python, for logic no block covers. The API lives in agent_runtimes.apps, in agent-runtimes itself — no other package to install. It uses the words of the Appspec: an application, its rules, its settings, a session.

Three pieces:

  • Application — the application: what it declares (its Appspec) and the code that reacts to its sessions.
  • AppHost — what runs it in this process: it opens sessions and tells the application what the person does.
  • Session — one conversation of one person with the application, handed to its code: to write, stream, show steps, ask, keep state, record, and call the application's agent. What the person sees goes through a Channel.

What runs today, and what waits​

Everything on this page runs in process, today: an application is declared, a host opens its sessions and drives them, and its agent answers with its rules, checks and record on. It is tested (see the end of the page).

A Python application of your own runs on a deployment's runtime (since 1.3.9x, R-14 and P-24): pushed with loop apps push app.py --app <id>, the file is kept with the version, and the runtime of a deployment — its scheduled ticks, its always-on runtime, the hosted page's sessions — is handed the code when its agent is made, and runs its start, message and schedule handlers through the Session API. The deployed version is the authority: an app.py whose SPEC says no version runs as the version stored, and one that says another application's is refused. A Preview, or a runtime with no deployment and no principal, is refused the code in a sentence. On your own machine, the same file is served mounted into your own FastAPI (see Mounted into your own FastAPI).

What does not exist yet:

  • An agent with tools in process. An application whose agent needs connections to MCP servers, skills, tools or a team runs on a runtime; built in this process, it is refused with the reason (see The agent).

Where its code runs​

On a runtime, never in a service (R-21). On the platform, application code runs on a runtime only — the sandbox a person's agent runs in, through the Session API — never inside ai-agents or durable. An app.py of your own is built on your machine into the spec it amounts to (loop apps build, push); the platform keeps the file beside the spec, versioned with it, and with each deployment of that version. The runtime reads it from the deployment when the deployment's agent is made (GET /api/ai-agents/v1/apps/deployments/{uid}/code?version=N on ai-agents, with the token of whoever opened the session, admitted as for the deployment's principal's token), wherever that agent is made: the hosted page — whether or not the deployment is kept always on — an embed, a tick, an always-on runtime. Every session of that agent runs your code: its handlers answer, not the plain agent. Code is never taken from the request that makes the agent: a request carrying app_code is refused (422), and a deployment's code that cannot be read stops its agent from being made, in a sentence. A deployment whose record does not say whether its version has code — one deployed before the file was kept — is refused (409): deploy it again.

Signed out, on the runtime it is kept on (R-30, decided 14 October 2026: kept runtime + refusal). A visitor without an account never talks to a Python application on the shared visitors' runtime, whose plain agent would answer in your code's place. ai-agents points them at the runtime the deployment is kept always on (runs_code and kept.url in what /apps/deployments/at/{slug} answers somebody signed out); that runtime takes their visitor's token for its own address only, makes the address's agent with the deployment's code (read as nobody, which ai-agents answers only for a deployment such a visitor may talk to), and holds them to what the visitors' runtime does: their own token for its models, so ai-inference's limits for visitors apply and nobody's account pays; reading only; their turns a day and minutes a conversation; nothing remembered and nothing recorded. So a Python application open to link or public must be kept always on: opening it to them, turning always on off while it is, or moving an open address to a Python version not kept on is refused (409, A Python application open to people without an account must be kept always on). One not kept on, or whose runtime is not running now, is refused to a visitor in a sentence, never run as the plain agent; the visitors' runtime refuses such an address too.

The services — ai-agents, durable — import only what reads a spec: load_app, schedule_triggers, session_payload; the scheduler starts a woken session on the runtime it launched, over HTTP (start_session). None of them loads an app.py, builds one, or runs a session in its own process; a test of each side keeps it so (test_app_code_runs_where.py here, test_no_service_runs_application_code.py in the services).

A runnable example​

A customer interview: it asks the person what they want to learn when the session starts, answers each message through its agent inside a step, and saves an insight from a button. The person is played by a MemoryChannel, and the agent by pydantic-ai's TestModel, so the script runs with no key and no network:

interview.py
import asyncio

from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel

from agent_runtimes.apps import (
AppHost,
Application,
ChoiceQuestion,
MemoryChannel,
Session,
)

# The application: what it declares is its Appspec.
app = Application(id="customer-interview", kind="chat", agent="cog-crawler:0.0.1")
app.starter("Onboarding", "Interview me about my onboarding.")
app.rule("send the summary by email", applies_to="send", behaviour="ask_first")
app.record(include=["outputs"])
app.setting("tone", {"type": "string", "title": "Tone", "enum": ["warm", "dry"], "default": "warm"})
app.setting("depth", {"type": "integer", "title": "Depth", "minimum": 1, "maximum": 5, "default": 3})


# What it reacts to.
@app.start
async def opening(session: Session) -> None:
session.state["goal"] = await session.ask("What do you want to learn?")


@app.message
async def reply(session: Session, text: str) -> None:
async with session.step("Thinking", kind="model", input=text) as step:
answer = await session.agent.run(text, goal=session.state["goal"])
step.output = answer.text
await session.send(answer.text)


class Insight(BaseModel):
insight: str


@app.action("save", payload=Insight)
async def save(session: Session, insight: Insight) -> None:
session.state["kept"] = await session.record(insight.model_dump(), summary="An insight")
keep = await session.ask(ChoiceQuestion("Keep interviewing?", ("Yes", "No")))
session.state["done"] = keep == "No"


async def main() -> None:
channel = MemoryChannel()
host = AppHost(
app,
channel,
# A model that answers without being called: no key, no network.
agent=lambda spec: Agent(TestModel(custom_output_text="Tell me about week one.")),
# No recorder: the record is kept in memory, `host.recorder.entries`.
)

channel.reply("why people leave") # the answer to `start`'s question
session = await host.open(opened_by="ada", settings={"depth": 4})
print(session.state, session.settings)

await host.message(session, "Hello")
print(channel.messages[-1].author, "-", channel.messages[-1].text)

await host.settings(session, {"tone": "dry"})
channel.reply("No")
await host.press(session, "save", {"insight": "pricing is unclear"})
print(session.state)
print([type(q).__name__ for q in channel.questions])
print([e["kind"] for e in host.recorder.entries if e["kind"] == "output"])


asyncio.run(main())
$ python interview.py
{'goal': 'why people leave'} {'tone': 'warm', 'depth': 4}
Customer interview - Tell me about week one.
{'goal': 'why people leave', 'kept': True, 'done': True}
['TextQuestion', 'ChoiceQuestion']
['output']

kept is True because app.record(include=["outputs"]) says so: the application keeps nothing in its record unless its Appspec names it (see The record). Given no recorder, the host keeps the record in memory — host.recorder is an AppRecorder whose sent holds each body as it would have been sent, and whose entries flattens them — which is what a script or a test wants; a runtime, and loop apps run, give one that sends it to the session store (AppRecorder(app=app.spec, send=…)).

The scenario is the one agent_runtimes/tests/test_loop_app_api.py runs (interview(), hosted()), where the model is scripted turn by turn.

Application​

from agent_runtimes.apps import Application

app = Application(
id="customer-interview",
kind="chat", # chat, widget, decision or worker
agent="cog-crawler:0.0.1", # or team="…"
name="Customer interview", # its id in words when unsaid
description="Interviews a customer about their onboarding.",
instructions="Never ask a leading question.",
model="", # in place of its agent's model
goal="", # for a worker
version="0.0.1", # optional: see below
protocols=[], # ids of agentspecs' protocols: ["pap"]
ui=[{"plugin": "chat"}], # its UI plugins: see below
)

Its interface. ui lists the Reactor plugins of agentspecs' ui it is drawn with, each {plugin, renderer} — renderer, one of ui-renderers the plugin may be exposed through, when that part reaches a host that is not Datalayer's own page. It says what its kind and layout put on its page: chat for a conversation, a2ui-surface through a2ui when it answers with A2UI cards, pap-sign-in when it signs people in at companies, page-layout, app-page and canvas-blocks for a page, app-page alone for a decision, app-components for components of its own. A plugin the catalogue does not have, or a renderer's id said alone (ui=["a2ui"]), is refused:

app = Application(
id="quote",
kind="widget",
agent="jupyter-data-analyst:0.0.1",
ui=[
{"plugin": "chat"},
{"plugin": "page-layout"},
{"plugin": "app-page"},
{"plugin": "canvas-blocks"},
],
)

Its version. Leave version out and the application is at 0.0.1 while you run it here, and at the version it is deployed as on the platform: the version the Studio stored is the authority, and a code that says no version of its own takes it (app.declared_version is None). Say a version — version= here, or version: in the spec Application.from_spec reads — and the runtime checks it: a deployment of 0.0.4 whose code says 0.0.2 is refused, The code says it is customer-interview 0.0.2, not 0.0.4 the agent runs: its code and its Appspec are of different versions. Code of another application is refused whatever its version.

Or attach code to an Appspec that already exists — read from its YAML, or as an AppSpec:

app = Application.from_spec(spec)

Nothing is validated at the call: the spec is checked when it is read — app.spec, which the host reads as it starts — so a worker read from a spec without triggers may still get its schedule from @app.schedule before.

Declarations: plain calls​

What an application declares is its Appspec. Each call adds to it:

CallDeclares
app.starter(label, message, *, category="", profile=None)A first message offered to the person, under its category when it has one (P-20): the starters of a category together, under its name, those without one first. With profile, it is that profile's, offered in place of the application's.
app.profile(id, label, *, description="", instructions="", model=None)One of several assistants in one application (P-20): two at least, or none. The person picks one before the conversation starts — the first unless they pick another — and keeps it to the end: its instructions are told to the agent in every run, its model is run in place of the application's (a mode's model wins over it), its starters offered. session.profile says it.
app.setting(name, field, *, required=False, widget=None)A setting of the session: a field of the settings' form (C-16), interface.settings, the JSON Schema of an object of named fields. field is the field's own schema — a type, the title the person reads, a default, and what it takes (enum, minimum, maximum…). widget is the input it is drawn with when not its field's own (interface.settings_ui, P-20) — see The nine inputs. Drawn with @datalayer/primer-rjsf beside the conversation and on a deployment's Ship card. A field that is not a schema raises TypeError; the old app.setting(id, type, label, …) is gone.
app.translation(language, words)What a person reads of it in another language (P-26), by BCP 47 tag: words keyed as interface.translations are — name, description, welcome, starters by label, categories, settings by field, commands by name, modes and profiles by id. The page shows the person's language when it has it, else its own words.
app.rule(says, *, applies_to, behaviour)When it acts alone and when it asks: says is the rule in the words a person reads (the Appspec's action); applies_to names classes of action (send, write…) or tools (server.tool); behaviour is do_it, if_asked, ask_first or leave_to_me.
app.record(include, *, keep_for=None)What the record keeps of each session (record.include, the Appspec's words: conversations, actions, decisions, checks, approvals, outputs, feedback, diffs, terminals, audio) and for how long (keep_for, 1_years, 90_days…). Nothing is kept unless said — see The record for which entry goes under which word.
app.connection(server, *, access="read", acts_as="owner", only=())An MCP server it reaches, read or write, in the owner's or the person's name, limited to the tools only names.
app.company_connection(id, company, *, access="read", acts_as="user", scopes=(), interfaces=("agent",), sign_in=("direct", "device"), extensions=())A company it works with as each person's agent, over the Personal Agent Protocol (pap.connections): reached through Datalayer's gateway, its tools named with the company. company is a domain, never a URL; a credential or an unknown field is refused at the call. See Both PAP roles.
app.as_company(*, scopes=(), excluded_tasks=(), signed_out=True, interfaces=("agent",), extensions=(), personal_agents="any", registered_agents=())The application is itself a company personal agents work with (pap.company): each scope {name, description}, with its hints changes and personal_data when you give them; each excluded task {task, alternative}.
@app.company_operation(action, *, summary, user_approval_required=True)Over an @app.tool: what a personal agent may ask the company to do, proposed with its terms and done by that tool once the person confirms them (pap.company.operations).
app.business_connection(id, brand, *, audience, access="read", scopes=())A business's own agent it asks for each person, over PACT 1.0 (pact.connections): brand its domain, audience what its Provider assigned the application, read asking as the application alone and write also for each person who signed in there, scopes the business's permissions it may ask a person for. Lists pact among its protocols. See A business over PACT.
app.as_business(*, registered_agents, scopes=None, brand="")The application answers people's personal agents as a PACT 1.0 business (pact.business): the personal agents it accepts, each {issuer, jwks_uri}; the scopes a person may delegate, {scope: what they read}; the Brand on its card. Lists pact among its protocols.
app.command(name, description, *, prompt="")A slash command in the composer (P-19): typing / lists it, picking it sends prompt, {input} the words typed after it. Without a prompt, used as a decorator, its code answers it — see below.
app.mode(id, label, options, *, default=None)A mode switch beside the composer: options are two at least, each {id, label, description?, instructions?, model?}. The option the person picks goes with every run: session.modes[id] says it, and session.agent is told its instructions and runs on its model.
app.ui.<component>(id, **properties)A component of the catalog on its surface, typed: app.ui.table("runs", columns=["model"], rows={"path": "/runs"}). One method per component (table, chart, form, text_field…), its properties typed from the catalog's JSON Schema and checked against it; app.component(id, "Table", **properties) is the same, untyped. See typed calls.

Both Personal Agent Protocol roles​

An application works with companies for each person, is a company itself, or both — the way two companies work together (S-04). The calls write the pap block the Spec and the Canvas write, checked by agentspecs' own models; loop apps build writes the same Appspec:

app = Application("books", "chat", agent="example-blank:0.0.1")

# A personal agent at Northwind, in each person's own account.
app.company_connection(
"northwind", "northwind.example", access="write",
scopes=["invoices:read", "invoices:write"], extensions=["operations"],
)

# A company to its own users' agents.
app.as_company(
scopes=[
{"name": "books:read", "description": "Read your books", "changes": "none", "personal_data": True},
{"name": "books:write", "description": "Close a month you approved"},
],
excluded_tasks=[{"task": "File a tax return", "alternative": {"instructions": "File it with your accountant."}}],
extensions=["operations"],
)

@app.company_operation("books.close_month", summary="Close {month} for {company}.")
@app.tool(does="write")
def close_month(month: str, company: str) -> dict:
"""Closes a month's books."""

The handlers are the application's agent and its own tools; no protocol secret reaches them — Sessions, tokens and proofs are Datalayer's gateway's and company service's. The examples personal-agent-protocol/company-connection and personal-agent-protocol/company-role are written with these calls.

A business over PACT​

A personal agent at a business that answers over PACT (Decagon's and Instinct's Personal Agent Consent & Trust) is one call; it writes the pact block Add a business writes in the Studio, and lists pact in protocols:

app = Application("travel-desk", "chat", agent="example-blank:0.0.1")

app.business_connection(
"example-air", "air.examples.datalayer.run",
audience="https://air.examples.datalayer.run/a2a", # what its Provider assigned it
access="write", # also for each person signed in there
scopes=["bookings:read", "bookings:change"],
)

A URL for the brand, a second connection to the same business or with the same id, and an unknown level are refused at the call, and nothing is kept. The gateway's pact toolset reaches the business, signs the personal-agent JWT with the application's own key and holds each person's delegation; the application never sees either. PACT is the protocol's page; PAP and PACT compares the two.

The application can also answer people's personal agents as a PACT business (pact.business): published at its deployment's own address when it ships, its card naming Datalayer as the Provider and brand (its owner when empty) as the Brand, accepting only the personal agents registered here:

app.as_business(
registered_agents=[{
"issuer": "https://agent.example/pact",
"jwks_uri": "https://agent.example/pact/jwks.json",
}],
scopes={"bookings:read": "See your bookings"}, # what a person may let one do
brand="Example Air",
)

The nine inputs​

A setting is drawn with one of nine inputs (P-20), each a JSON Schema field and, where needed, the widget that draws it — the setting's ui:widget in the Appspec's interface.settings_ui:

Inputfieldwidget
Select{"type": "string", "enum": [...]}—
Slider{"type": "integer", "minimum": 1, "maximum": 10}"range"
Switch{"type": "boolean"}"switch"
TextInput{"type": "string"}— or "textarea"
Checkbox{"type": "boolean"}—
DatePicker{"type": "string", "format": "date"}—
MultiSelect{"type": "array", "uniqueItems": True, "items": {"type": "string", "enum": [...]}}— or "checkboxes"
RadioGroup{"type": "string", "enum": [...]}"radio"
Tags{"type": "array", "items": {"type": "string"}}"tags"
app.setting("seats", {"type": "integer", "title": "Seats", "minimum": 1, "maximum": 50}, widget="range")
app.setting("live", {"type": "boolean", "title": "Live data"}, widget="switch")
app.setting("topics", {"type": "array", "title": "Topics", "items": {"type": "string"}}, widget="tags")

A widget that cannot draw its field is refused when the spec is read: a slider without a minimum and a maximum, a switch for a string.

Profiles​

app.profile("support", "Support", instructions="Answer from the documentation, briefly.")
app.profile("sales", "Sales", description="Plans and prices", model="anthropic:claude-sonnet-4-5-20250514")
app.starter("Pricing", "What does the Business plan cost?", category="Plans", profile="sales")
app.translation("fr", {"profiles": {"sales": {"label": "Ventes"}}, "starters": {"Pricing": {"label": "Tarifs"}}})

@app.message
async def reply(session: Session, text: str) -> None:
answer = await session.agent.run(text) # told the profile's instructions
await session.send(f"[{session.profile}] {answer.text}")

The page offers the profiles in a picker beside the composer and sends the one picked with every run (forwardedProps.loop.profile); the empty chat offers its starters. Once the first message is sent, the conversation keeps its profile: a run naming another is refused (409, This conversation is with Sales: start another to talk to Support.), and one naming a profile the application does not have too (422).

app.spec is the Appspec they amount to, validated as any Appspec is: an application that would not run raises AppNotRunnable with its reasons, in sentences. app.document is the same as a plain dict, as its YAML would say it.

Reactions: decorators​

What an application reacts to, one handler per moment — sync or async, the session always first:

DecoratorCalled withWhen
@app.start(session)A session starts.
@app.message(session, text)The person writes. Without a handler, the application's agent answers, streamed.
@app.action(name, payload=Model)(session, payload), or (session) without a modelThe person presses the button of that name (RU-5). Typed: payload is the pydantic model of what it is pressed with, its JSON Schema written in the Appspec (interface.actions); what a press carries — a Form's values, a row — is checked against it first, a field it does not name refused too: refused, the handler is not called (ValueError, or 422 from the Session API). The handler is given the model; an action declaring none takes nothing. Only a button its code showed, or the session it runs in, offers it, with a token; a press of an action it does not declare is refused, and what a press carries is never said to the model. description= says what it does (its docstring's first paragraph otherwise).
@app.file(session, files, text)The person sends files with a message without being asked (P-21): the UploadedFiles, and the words sent with them. Which kinds and sizes may be sent is the Appspec's interface.uploads; the runtime refuses the rest before this runs. Without a handler, the words go to message and the files wait for what the code asks. See What a person sends.
@app.settings(session, settings)The person changes settings; given all of them, checked. A change made on the page goes with the next message, and this runs before that message's handler (P-20).
@app.window(session, data)The page the application sits in posts a message (P-25): element.postWindowMessage(data), on your own server. See Window messages.
@app.stop(session)The person presses Stop, after what was running is cancelled.
@app.resume(session)An earlier session is reopened, its state back — an ended one too.
@app.feedback(session, feedback)A person gave a thumb up or down on an answer, with a comment or not, and it is kept in the record (only when the Appspec's record.include names feedback). feedback is a Feedback: liked, comment, message (the answer it is about, answer-<n>) and by. See Threads and feedback.
@app.audio(session, heard)The person spoke (P-22): the turn's sound, given to host.audio in chunks, heard by the same engines as a page's and loop --voice's — on the machine when the pinned Whisper and faster-whisper are there, else by Datalayer's speech service with the person's token. heard is a HeardSpeech: text, language, engine and where (device or server). Without a handler, the words are the person's message, marked spoken. The sound is never kept. See Voice.
@app.interrupted(session)The person spoke over the agent: what was running is cancelled, the page told to stop the voice, then this runs. The conversation stays open for what they say next.
@app.end(session)The conversation closes for good, after what was running is cancelled: the person left the terminal, or a client ended the session. The last thing it runs; nobody reads what it sends.
@app.logout(session)The person signed out. The session then ends: end runs after it.
@app.command(name, description)(session, words)The person sends /<name> words: the code answers in place of message. Declared in the Appspec with the prompt /<name> {input}, and marked as code by loop apps build (command <name>); in the terminal, /<name> runs it too.
@app.schedule(cron, *, name="", description="", prompt="")(session)On a schedule, in a session of its own, known by name (the handler's when unsaid): host.tick(name). Also adds the schedule to the Appspec's triggers. Refused at the call on anything but a worker — a chat starts when somebody opens it.
@app.page / @app.page(live=False)([session], **inputs)A widget's page (P-05): its inputs changed on the page, or Run pressed with live=False. Returns its outputs. See A widget's page.

A second handler for the same moment, action or schedule raises ValueError.

A widget's page​

A widget can be a function of its inputs, like a notebook cell with sliders (P-05): its page draws the inputs as a form and the outputs below them, and as an input changes, the function runs again on all of them and the outputs change in place — no message, no conversation, on the same page and renderer as a chat application.

from typing import Literal

from agent_runtimes.apps import Application

app = Application(id="quote", kind="widget", agent="jupyter-data-analyst:0.0.1")
app.output("total", title="Total")
app.output("lines", "Table", columns=["item", "amount"])
app.input("seats", {"type": "integer", "title": "Seats", "minimum": 1, "maximum": 1000, "default": 10})


@app.page
def quote(seats: int = 10, plan: Literal["Team", "Business"] = "Team") -> dict:
price = 12 if plan == "Team" else 30
return {
"total": f"{seats * price} €",
"lines": [{"item": plan, "amount": seats * price}],
}

Inputs are the function's parameters, by name: each typed by its annotation — int, float, bool, str, a Literal or an Enum of them, a list of them — its default its default, one without a default required. app.input(name, field, required=False) says one more precisely (a title, a minimum, a maximum), as app.setting does; the field it says wins. Together they are a form (C-16), drawn on the page at /inputs/<name>. The function takes the session first when it wants it (session.agent, session.state), and **inputs to take inputs it does not name.

Outputs are declared with app.output(name, component="Text", *, title="", **props): Text shows words (a number is shown as words), Image an address (a URL or a data URL), Table rows and Chart points — each a list of records, a pandas DataFrame too — the component's other properties checked against the catalog as app.ui checks them (a Table's columns, a Chart's kind, x and y). The function returns the value of its one output, or its outputs by name, all of them. Declaring none, the page has one: result, in words.

Both are its Appspec's interface.page — function, inputs, outputs, live — so that the Canvas shows them and validation runs them; loop apps build marks the function as code (# loop:code page: quote (app.py:9)).

WhatRefused
Declaringapp.output with another component (ValueError), properties its schema refuses, or a name used twice; app.input of what is not a JSON Schema, or of an input the function does not take (TypeError); a parameter of a type a form does not ask, or taken by position (TypeError); app.action("page"), the action that runs the page; a page on an application that is not a widget, or inputs declared and no @app.page (AppNotRunnable from app.spec).
RunningInputs its form refuses — out of range, not one of its options, an input it does not have — in a sentence (ValueError; 422 from the Session API); what it returns when an output is missing or unknown, or a value its component does not draw.

AppHost.page(session, inputs) runs it in process and returns its outputs; the page sends its inputs as the page action of its session ({"inputs": {...}}), and the outputs come back as a loop.page event. The terminal says them. loop apps validate app.py --tests --local runs the page on its inputs' defaults — It shows each of its outputs for its inputs' defaults. — and fails when the function raises, returns what its page does not show, or an input it requires has no default (a page shows its outputs from the start).

Code where plain words are not enough​

A rule, a test in words, a catalogue check say most of what an application needs. Where they cannot, its code says it (P-06) — and declares it in its Appspec, so that the Canvas shows it (What its code decides: its tool, its check, its test, each with the line that decides it) and validation runs it:

from agent_runtimes.apps import Application, Conversation

app = Application(id="order-desk", kind="chat", agent="worker-support:0.0.1")
app.rule("Refund an order", applies_to="refund", behaviour="ask_first")


@app.tool(does="read")
def lookup_order(number: str) -> dict:
"""Find an order by its number."""
return ORDERS[number]


@app.tool(does=["write", "buy"])
async def refund(number: str, amount: float) -> str:
"""Refund an order."""
return await PAYMENTS.refund(number, amount)


@app.check("answer")
def no_prices(text: str) -> str | None:
"""It never quotes a price."""
return "It quoted a price." if "$" in text else None


@app.check("tool_call", description="Refunds stay under 100.")
def small_refunds(tool: str, arguments: dict) -> bool:
return tool != "refund" or arguments["amount"] < 100


@app.test("It gives the order's status", ask="Where is order 42?")
def gives_the_status(conversation: Conversation) -> bool | str:
return "shipped" in conversation.answer or "It did not say the status."
DecoratorDeclares in the specRuns
@app.tool(does=…, description="")tools: its name (the function's), its description (the docstring's when unsaid), its parameters — the JSON Schema of its arguments, from their types — and what it does, by class of action.Its agent is given it beside its own tools; sync or async, called with its arguments, what it returns is what the agent reads. Every call is decided by the rules, as any tool's: by a rule that names it by its name alone (applies_to="refund"), else by what it does — reading needs no rule, anything that acts asks first.
@app.check(on, description="")checks.code: its name, its stage, what it checks in words (the docstring's first line when unsaid).At its stage, after the built-in checks and before the record: answer, handler(text) before the answer is given — refused, the agent is asked again with why; tool_call, handler(tool, arguments) once the rules let the call through — refused, the call is stopped (AppCheckBlockedError). None or True lets it pass, False or a sentence refuses; anything else raises. Each refusal is a check entry of the record.
@app.test(expect, *, ask)A case of tests.cases, its code the function's name.loop apps validate app.py --tests --local: the case asked of the application in this process, a session of its own, and its Conversation — ask, answer (its messages, one after the other), messages, steps, asked (what it stopped to ask the person, when it did) — handed to the function, which returns True, False or a sentence saying why it failed. None is not a pass: it is said, and the test is not run. Without the file — on Datalayer, through the Evals engine — the case is judged by its words.

@app.tool without does raises TypeError; a tool or a check that says nothing of itself, a stage that is neither answer nor tool_call, or a name used twice raise ValueError; what agentspecs refuses — a class of action that is none — raises AppNotRunnable from app.spec.

loop apps build marks each as code, # loop:code tool lookup_order: lookup_order (app.py:7), check …, test …. A spec validated alone — its app.yaml without the file — needs attention for each: its tools are not given, its checks check nothing, its tests are judged by their words.

loop apps validate app.py --tests # lists them
loop apps validate app.py --tests --local # runs them here; exit 1 when one fails, 3 when one could not run

Schedules on the platform​

A deployed application's schedules fire with nobody at a browser. When an application is deployed, each schedule trigger of the deployed version is registered with the platform's scheduler; on each tick the scheduler launches a runtime — named for the application and the deployment, so its time is counted against them on Usage — creates the application's agent on it as the hosted page does, opens a session of it on the Session API said to be woken by that schedule, and stops the runtime. The session runs the schedule's handler, or asks the agent the trigger's prompt (its description when it has no prompt). Pausing the deployment stops its schedules, resuming starts them again, taking it down removes them; deploying another version registers that version's schedules. Every tick is a run of the schedule, which says whether it answered.

Your code is the deployment's. The app.py that loop apps push kept beside the version's spec is kept with the deployment, and the runtime of each tick reads it from there as the agent is made (the payload that creates the agent carries none), then runs the session with it — its start, message and schedule handlers, its tools and checks — under the application's rules, as loop apps run does. The runtime reads code for a deployment's agent only, once it holds the deployment's principal token, with the same caller's token. A version written as a spec has no code, and its agent is asked the prompt. The stream of a woken session ends only once its turn is on the record, so a scheduler that stops the runtime as the stream ends loses nothing of it. A tick run by your code is recorded as an agent's run is: the run, its turn — the trigger's prompt and what your handler sent — and its output, beside the steps it took, as far as the Appspec's record.include keeps them. Each tick reserves a bounded amount of credits for its runtime — the deployment's spend limit when its owner set one, else the platform's own small reservation — never a share of what the owner has left.

A deployment kept always on runs your code too. Its version's app.py is kept with the deployment as it is deployed or moved to another version, beside its spec, and the runtime it is kept on reads it from the deployment each time its agent is made there (kept_payload carries none), with the key it was started with — also when the platform starts it again with nobody present, which reads no Spacer item; the runtime of a version it moves to, made before the move is written, reads that version's. Every session of that agent — a visitor's at the address, a tick's — runs with it. A deployment kept before its code was kept with it is not guessed to have none: it stops being kept on, saying it is to be deployed again.

A @app.schedule handler runs on the tick when the runtime runs the application's code — an app.py of the catalogue, at the version the runtime has: the scheduler knows a schedule by its position among the application's triggers, the runtime finds the handler the code declared there (app.schedule_at(position)) and runs it with the session, in place of the prompt, as a command's code runs — what the platform holds for loop.app.schedule under <id>/<name> (AppHost.tick(name, session=…)). The code's start runs first, as in any session opened. A schedule the code did not declare — one of the Appspec's own — asks the agent its prompt. When the trigger at that position is not the schedule the code declared (another cron: code and Appspec of different versions), the session is refused and the tick fails, saying so. An app.py of your own runs on the tick the same way: the deployment's runtime is handed its code, and its schedule handler runs there (host.tick(name)), with nobody present.

Woken, nobody is present: the session only reads unless one of the application's rules says otherwise (see With nobody present in the app renderer) — its handler's session.agent and its agent alike. A handler that asks somebody something (session.ask) ends the tick as waiting: nobody is there to answer.

A schedule with neither a prompt nor a description is refused at deploy: a session woken with nothing to ask does nothing. An application run by a team is not woken yet.

The record of a session nobody opened says what woke it: woken_by, {"kind": "schedule", ...} — the schedule, its cron, and on the platform the scheduler's run — sent with its entries and written on its first.

In whose name it acts on the platform​

A deployed application acts as itself: its service principal, made at its first deploy, with a token narrowed to what the version it runs was granted — never with the token of the person who launched the runtime.

  • What it may reach is read from the deployed version's Appspec — each connection (its server, read or write, in whose name, its only), each Space, and the parts of its computer turned on — and kept by the platform as the deployment's grant (RFC 9396 authorization_details). An application granted nothing has an empty list, and reaches nothing.
  • Its token. The agent of a deployment is created on a runtime with app_instance.deployment_uid and the token of whoever opens its session (the hosted page, the scheduler's tick). The runtime asks ai-agents for the principal's token with it (POST /api/ai-agents/v1/apps/deployments/{uid}/principal-token) before the agent is made, keeps it in memory by deployment (agent_runtimes.apps.principal), and asks again, with the latest request's token, when less than five minutes of its hour are left.
  • What it is used for: the agent's model calls through ai-inference (metered on the application's owner, naming the deployment and the principal), the Datalayer MCP gateway, and the session's record. The runtime's inference token and DATALAYER_USER_TOKEN are never used in its place.
  • Fail fast. Without the token the agent is not created (422, in a sentence); once it has expired the agent calls nothing and says so, and a chat request is answered 403.
  • Withdrawn, at once. Every service reading the token asks whether its grant still stands, and remembers the answer ten seconds. Moving the deployment to a version granting less, pausing it, taking it down or deleting the application revokes the grant, so the token stops being honoured within ten seconds, not at its expiry.

A Preview, a test run and an agent no application runs keep the runtime's own inference token: they are a person trying something, not a deployment acting alone.

A Preview is held to the same Space grants (R-25): though it runs as the person trying it, the tools of its session reach only the Spaces its Appspec grants (permissions.spaces), each as far as it says — what is tried is what ships. The runtime asks ai-agents, with the person's token, for a token of theirs narrowed to those Spaces (POST /api/ai-agents/v1/apps/{app_uid}/preview-token, minted by IAM for an hour, only for an owner or editor of the application), keeps it beside the principals' and renews it the same way; its runs carry it in place of the person's (see Session API). An Appspec granting no Space reaches none. Its model calls do not change.

Its notifications​

An Appspec's notifications names where an approval reaches a person — email, slack, teams — and those are the channels the runtime sends through for the application's sessions (agent_runtimes.apps.notifications). Its agent's own notifications are not used: an application's agent is given no send_notification tool.

  • When. Each time a rule (Ask me first, Do it if I asked) or a Gate asks a person through the tool-approval path — where nobody may be watching — before the question waits. A question asked in the session itself (the terminal, session.ask) is not notified: the person is there.
  • How. The runtime asks ai-agents, once, with the channels the catalogue offers (POST /api/ai-agents/v1/apps/notifications, event approval.requested). ai-agents mails the account's own address through Datalayer's mail, and posts to the Slack incoming webhook that account keeps among its secrets as SLACK_WEBHOOK_URL.
  • As whom. A deployment asks with its principal's token, so what is sent reaches the application's owner, in the owner's channels (the principal may read that one secret of its owner's, by name, and no other). A Preview asks with the person's token: it reaches the person trying it, in their own channels, and nobody else.
  • Never silent. A channel Datalayer does not offer (Teams today) is refused before anything is sent, in the sentence the Studio's setup states say; an account with no address, no SLACK_WEBHOOK_URL, or a webhook that refused comes back from ai-agents with its sentence. Every channel's outcome — sent, refused or failed — is written to the record as a notification entry, whatever record.include says, and logged when it was not sent. Sending never stops the question it is about.

Approved in advance​

A rule that says if_asked — Do it if I asked — acts only on what the person approved before, and asks otherwise (agent_runtimes.apps.grants). Never from a model's reading of the conversation.

  • What an approval is. Given by the person in the Studio — send replies to my team, until Friday — and kept by IAM as a grant of theirs: the application, one action (send, buy, delete, publish, write), whom it reaches (addresses, @domains, #channels; none for anyone), their words, and an end at most 90 days away. They read it back and revoke it there; it mints no token.
  • What it covers. A call of that application whose classes are that action, or that action and reading — an approval to send never covers a tool that sends and deletes — and, when it names recipients, every recipient the call's arguments name (to, cc, bcc, recipients, attendees, channel…) is one of them. A call naming nobody is not covered by an approval that names somebody.
  • Where it is read. At the tool call, before the person would be asked, from GET /api/iam/v1/oauth/task-grants/approvals/standing?app_uid=, with the token the application acts with: a deployment's principal's (IAM answers its owner's approvals, for that application only), else the person's whose Preview it is. Answers are kept ten seconds, so a revoke is honoured within them. Approvals that cannot be read are none: the person is asked, and the record says why.
  • What is recorded. The decision entry of a call done because of an approval names it (approved_in_advance, approved_words).
  • An application the platform does not know (no app_uid, a local run) has no approvals: if_asked asks, as ask_first does.

Every approval the runtime asks for carries, beside the call's arguments, the rule or Gate it was asked under (_rule, _check) and the application (_app, and _app_uid when the platform knows it), so its own page, the Tool Approvals page and its channels say whose it is.

AppHost​

AppHost(app, channel, *, agent=local_agent, recorder=None, platform=None) runs an application in this process. A runtime, a test or a script drives it. host.recorder is its AppRecorder: in memory when none is given.

MethodDoes
await host.open(opened_by=None, settings=None, uid=None, modes=None, profile=None)Opens a session and runs start. Returns the Session; opened_by is who opens it (on the record too), uid names the session when the caller does — the session API's uid.
await host.message(session, text)Runs message, or lets the agent answer.
await host.press(session, name, payload=None)A press of a button: runs the action of that name; an unknown one raises KeyError.
await host.files(session, files, text="")Runs file with the files sent without being asked; without one, the words go to message.
await host.window(session, data)Runs window with what the page posted; without one, raises KeyError.
await host.settings(session, values)Checks the values against the settings' form, keeps them, runs settings. A field the form does not have, or a value its schema refuses, raises InvalidAnswer (“Its settings” was sent what its fields refuse: depth: 9 is greater than the maximum of 5.).
await host.stop(session)Cancels what runs in the session, then runs stop.
await host.audio(session, chunks, content_type="audio/wav", language="", token=None)Hears the turn's sound — bytes, or an iterable or async iterable of bytes — then runs audio, or sends the words to message marked spoken. A WAV (16-bit mono at 16 kHz) is heard on the machine when it can be; another type, or without the model here, by the speech service with token. What cannot be heard raises AudioNotHeard, in a sentence. Returns the HeardSpeech.
await host.interrupt(session)The person spoke over the agent: cancels what runs, tells the page (voice.interrupted), then runs interrupted.
await host.resume(uid, state, opened_by=None, settings=None)Reopens a session with its state, and runs resume — an ended one too.
await host.end(session)Cancels what runs in the session, runs end; the session then takes nothing more — a message, an action, settings or Stop raise ValueError (Session … has ended.). Ending it again does nothing.
await host.logout(session)Cancels what runs, runs logout, then ends the session.
await host.tick(name, session=None)A tick of the schedule name, now: in a new session whose record says the schedule woke it, or in session, one already open — the one a tick woke on a runtime. Returns the session it ran in.

Each handler runs as one turn: it can be stopped, and the record of the turn is sent when it ends. agent says how the application's agent is built (local_agent unless said); recorder where its record goes (kept in memory unless said: AppRecorder.in_memory(spec), read from host.recorder.entries).

load_application(path) returns the one Application an app.py defines; a file with none, or more than one, raises ValueError.

A Reactor plugin​

An application written in Python is a Reactor plugin, as one written as a spec is: loop-app-<id>, its manifest the application's identity (name, version, description, emoji, kind) — the name its page's plugin has too, which the manifest names as what it cannot be used without (frontend_dependencies), both delivered as one extension of that name (one application, two plugins). loop apps eject changes nothing of it: the app.py it writes is the same pair as the spec it was written from. Each decorator is a contribution of that plugin to a point of its own — loop.app.start, loop.app.message, loop.app.settings, loop.app.stop, loop.app.resume, loop.app.end, loop.app.logout, and loop.app.action and loop.app.schedule known as <id>/<name> — beside its spec on loop.app.

An AppHost registers the application on a Reactor PluginPlatform — its platform, one of its own unless given, started with it — as one extension of that name (register_application, with Reactor's register_extension_object) and calls what the points hold (reaction_of): a later contribution to the same moment of the same application, another plugin's, answers in its place, and is gone when that plugin is unregistered. The platform's switches are Reactor's: a disabled application's reactions are not read. host.dispose() takes the application's extension away (unregister_extension) — its spec, every reaction and contribution, the plugins it delivered; an action then raises KeyError. loop apps run and a runtime's session load it the same way, each session's host with a platform of its own; the runtime's own platform (plugins.PLATFORM) holds agent-runtimes' plugin, the catalogue, and the plugin of each application it is configured with.

from reactor import PluginManifest, PluginPlatform
from agent_runtimes.apps.plugins import REACTION_POINTS

platform = PluginPlatform()
host = AppHost(app, channel, platform=platform)

async def louder(session, text):
await session.send(text.upper())

class Shouting:
def provide_contributions(self, contributions):
contributions.contribute(
REACTION_POINTS["message"], louder, contribution_id=app.id
)

# Answers in place of app's own @app.message, until unregistered.
platform.register_plugin(PluginManifest(name="shouting", version="1"), Shouting())

Extending with Reactor​

The application's plugin is yours to extend, in Reactor's own vocabulary — whatever a Reactor plugin can do, an app.py says the same way, and the host that runs it honours it.

CallIs, in ReactorHonoured by
app.manifestIts PluginManifest: loop-app-<id>, the plugins it uses as its dependencies, its own points as its contribution_points.Every host: listed, switched, drawn on the graph.
app.extension(frontend=None)Its ReactorExtension: the plugins it composes and its own plugin; its frontend half, its page side when packaged (below).register_extension_object; the entry point loop apps package writes.
app.contribute(point, value, id=None, order=0)A contribution to any point — one of agent-runtimes' (loop.app.start…), one of a plugin it uses, one of its own.Whoever reads the point, on the host's platform.
app.extend(point, target, value, id=None)An extension of one contribution of a point (PluginContributions.extend).get_contribution_extensions.
app.contribution_point(id)A point of its own (define_contribution_point), said in its manifest, that other plugins contribute to.Its code: session.contributions(point).
app.uses(plugin, tools={...})A plugin it composes — a (PluginManifest, implementation) pair, delivered in its extension and gone with it, or a ReactorExtension, registered on the host when it is not there yet and kept — a dependency, activated first. tools gives its agent the plugin's agent tools (provide_agent_tools) by name, each with what it does.The host's platform; its agent's tools, decided by its rules.
@app.reactor_command(id, name)A Reactor Command of its plugin (provide_slash_commands).platform.list_commands(), execute_command, session.execute_command.
app.command(name, description, run=id)A command of its composer that a Reactor command answers — its own or a used plugin's — with the words typed after it; what it returns is the answer.The composer (/name words), the terminal.
@app.route(path, methods=["GET"])A route of its plugin (provide_routes), with its endpoint.app.mount and loop apps run --web, under the application's path (Reactor's include_plugin_routes).
from agent_runtimes.apps import Application, Session
from support_crm import SOURCES, extension # a Reactor extension, installed

app = Application(id="support-desk-plus", kind="chat", agent="example-blank:0.0.1",
instructions="Look a customer up with lookup_customer.")

GREETINGS = app.contribution_point("support-desk-plus.greetings") # others extend it
app.uses(extension(), tools={"lookup_customer": "read"}) # its agent tool, given
app.contribute(SOURCES, {"name": "support-desk-plus"}, id="tickets")

@app.reactor_command("support-desk-plus.hours", "Opening hours")
def hours(argument=None) -> str:
return "Monday to Friday, 9:00 to 17:00 CET."

app.command("hours", "When the desk is open", run="support-desk-plus.hours")
app.command("lookup", "Look a customer up", run="crm.lookup") # the CRM's command

@app.route("/health")
def health() -> dict:
return {"ok": True}

@app.start
async def greet(session: Session) -> None:
await session.send(" ".join(c.value for c in session.contributions(GREETINGS)))

What a used plugin's agent tool does is said by the application, as for @app.tool: it is declared in its spec (tools), so that the Canvas lists it and a rule can name it, and every call is decided by its rules; it runs the Reactor command the plugin's bundle names, on the host's platform. A tool the plugin does not offer is refused (support-crm offers support-desk-plus no agent tool 'nothing': it offers lookup_customer.). loop apps build marks a command or a tool a Reactor command answers as such (tool lookup_customer: the Reactor command crm.lookup). A hook a plugin implements for Reactor's lifecycle (@hookimpl def on_reactor_start) runs when the host's platform starts; a route is mounted when the application is mounted, so a route changed under --watch is mounted again on the next start.

Not from Python: a point of the page — the Canvas's loop.canvas.part, a block of its palette — is the page's, contributed by the application's page side (below); the server's platform does not reach a browser.

examples/reactor-extensible/ is such an application, its CRM a Reactor extension beside it (support_crm.py): run on a real model in this process, its agent looked a customer up through the CRM's command.

Session​

A session is made by the host and handed to the application's code — never made by that code.

session.uidThe session's uid — the one word for it, in Python, on the wire and in the record: its conversation and its record.
session.opened_byWho opened it, when the host knows: the person the application talks to; embedded with user: signed, the sub the host's server signed (D-21).
session.stateA dict the application keeps across the turns of this session; each session has its own.
session.settingsThe person's settings, by id, their defaults filled in; a copy.
session.profileThe id of the profile the conversation is with (P-20): the first unless another was picked; None for an application without profiles.
session.appThe application, as its Appspec says it.

Voice​

An application whose Appspec has a voice (interface.voice) can say a short line aloud, beside what it writes:

@app.audio
async def heard(session, heard):
await session.say("One moment, I am looking that up.")
await session.stream(session.agent.stream(heard.text))

await session.say(text) says the line with the application's voice, at most 200 characters: the page says and captions it, as it does an agent's say_aloud line (voice.say on AG-UI), and it is not a message of the conversation — what matters is written as well. An application without a voice is refused (ValueError). In the terminal the line is printed, said aloud:.

What people say reaches @app.audio from wherever they say it:

  • On its page, the microphone hears what is said — in the browser, or on Datalayer's servers — and the words come to @app.audio as what was heard (where says which); the sound stays where it was heard.
  • At the terminal, loop --voice running the application: the second /voice (or Ctrl+T) gives the turn's sound to @app.audio, heard on your machine when the voice extra and the pinned model are there, else on Datalayer's servers — not to the prompt line.
  • From a program, through its session: POST /sessions/{uid}/audio with the turn's sound as the body (WAV, FLAC, Ogg Opus or WebM), the turn streamed back as a message's is (The session API).

A turn is heard once it has all come — the words, not partial words, are what @app.audio is given. Speaking over the answer, hands-free on the page or POST /sessions/{uid}/interrupt from a program, cancels what runs, stops the voice that was saying it (voice.interrupted) and runs @app.interrupted; the conversation goes on with what was said next.

Writing to the person​

  • await session.send(text, author=None) — a message, by the application's name unless said. Returns the Message.
  • await session.stream(tokens, author=None) — a message as it comes, piece by piece, from any iterable or async iterable of strings; each piece is delivered as a Delta, then the whole Message. await session.stream(session.agent.stream(text)) shows the agent's answer as the model writes it.

What an answer shows​

An answer shows more than text: the components of the catalog — the ones the Canvas places and app.ui places on the application's surface, checked against the same JSON Schema. session.ui.<component>(id, **properties) makes one, typed:

await session.send(
"Three runs, cheapest first.",
show=[
session.ui.table("runs", columns=["model", "cost"], rows={"path": "/runs"}),
session.ui.chart("costs", kind="bar", x="model", y="cost", points={"path": "/runs"}),
session.ui.image("plot", url="https://example.com/plot.png"),
],
data={"runs": runs},
)
  • show — components, drawn under the text in the order given; a component another names as its child (a Button's label) is drawn where that one puts it. Text, Image, Table, Chart, Button, Card, Row, Column, a file to download and the rest of the catalog.
  • data — what a property bound to a path reads (a Table's rows, a Chart's points): the answer's own data.
  • A Button's action (action={"event": {"name": "again"}}) is offered with a token and sent to the application when it is pressed, with what it read — checked against the model its @app.action("again") declares, which reacts (RU-5). A form's submission on any other surface stays the agent's next message.
  • send, stream and update take both; update keeps what the message showed unless told, and show=[] takes it away.
  • A component the catalog does not have, properties its schema refuses, two components of one id or a child no component is raise ValueError, in a sentence, before anything is sent.

A file to download is session.ui.download: the name it is saved as and where it is — an https:// link, or the file itself as a data: URL — with its kind and size said beside its name and a line under it. Pressing it saves the file; an action on it is sent to the application as a button's is. Any other link (javascript:, a path of the runtime) is refused by the schema before anything is sent.

import base64

csv = "month,total\nJan,1200\n".encode()
await session.send(
"Your export.",
show=[
session.ui.download(
"totals",
name="totals.csv",
url="data:text/csv;base64," + base64.b64encode(csv).decode(),
media_type="text/csv",
size=len(csv),
description="The month's totals.",
)
],
)

A data: URL travels with the message: keep it to files of a few megabytes, and give a link for anything larger.

On the channel, they are the Message's components and data. Over the Session API they are a surface under the message, drawn by the chat's A2UI renderer — the one a team's answers are drawn with on the home page; a terminal names each (▣ Table runs, ▣ Download totals: totals.csv). On the record, what an answer showed is kept with it, under outputs (record.include): each component by its kind, its id and the words it shows — a title, a label, a file's name — and the names of its data, never the data itself.

Where an element shows​

An element — components of the catalog, as an answer shows them — shows where it belongs (P-18): in the conversation, in a side panel beside it, or on a page of its own over it. Its code opens it, changes it and closes it:

panel = await session.show(
session.ui.table("runs", columns=["model", "cost"], rows={"path": "/runs"}),
where="panel",
title="Runs",
data={"runs": runs},
)
await panel.update(data={"runs": runs + [latest]}) # changed in place
await panel.close() # or session.close(panel.id)

await session.show([session.ui.text("intro", text="The report"), ...], where="page", id="report")
await session.close("report")
  • where — inline (the default): a message of the conversation, carrying it, its title the message's text; panel: the side panel on the trailing edge, several stacked in the order they opened; page: a page of its own over the workspace, the last opened shown.
  • title — over it; the application's name when unsaid. id — what the code names it by; a new one when unsaid. Shown again under the same id, it is changed in place; one open elsewhere is refused (ValueError): close it first.
  • data — what its components bound to a path read, as an answer's.
  • A Button on it sends its action to @app.action, as an answer's does.
  • session.elements — what is open now, by id. Closing one that is not open raises ValueError.
  • The person closes a panel or a page on their screen with its own button; the code is not told, and may show it again.

On the channel, a panel or a page is a Shown event, delivered again when it changes, and Closed when it goes (MemoryChannel.shown is what is open, as MemoryChannel.steps is the steps as they are now, and .messages the conversation); inline, it is a Message. Over the Session API it is a reactor.ui display that says where it shows (a remove op closes it), drawn by the app-elements plugin every application's workspace mounts; a resume draws again what was open, and its code can still close it. A terminal cannot open one: it names it (▸ Runs, shown in the side panel) and its components.

Components of your own​

When the catalog has no component for what you want to show — a gauge, a map, a diagram — write one (P-17). It is a component of this application only: its Canvas places it, its page draws it, its code shows it inline, in a panel or on a page, and a widget's page draws an output with it. No other application sees it.

app.custom_component(
"Gauge",
description="A dial, from nothing to its most.",
source="https://elements.example.com/gauge.js", # a built ES module
integrity="sha384-…", # the module reviewed
props={
"type": "object",
"properties": {"label": {"type": "string"}, "most": {"type": "number"}},
"required": ["label"],
},
shows=["value"], # bound to what the application publishes
sends=["chosen"], # written, then its action dispatched
)

app.component("load", "Gauge", label="Load", value={"path": "/load"}) # on its surface
app.output("load", "Gauge", title="Load", label="Load") # a page's output

await session.show(session.component("now", "Gauge", label="Now", value=42), where="panel")
  • It is declared in the spec, under interface.custom_components, and reviewed as a component of the catalog is: agentspecs refuses a name of the catalog's, a property of a type the renderer cannot tell apart, a binding that is also a property, an example its schema refuses, and a module that is neither a built ES module over https:// (or http://localhost while you write it) nor a .js or .mjs file inside the application's folder (no absolute path, no ..). Where it is used, its properties are checked against its schema — by app.component, session.component, session.show and the page.

  • session.component(id, component, **props) makes a node of the catalog or of the application's own; session.ui keeps the catalog's typed calls.

  • On a widget's page, its value is what it shows first (shows[0]): any value JSON writes, a DataFrame its rows.

  • The module's default export draws it into the element it is given:

    export default function draw(root, { props, send }) {
    const dial = document.createElement('meter');
    root.append(dial);
    const update = next => { dial.max = next.most ?? 100; dial.value = next.value ?? 0; };
    update(props);
    dial.onclick = () => send('chosen', dial.value);
    return { update };
    }

    A React component is bundled into such a module with its React. The module may also be a file of the application's folder, source="components/dial.js": see Its page side, as a package.

  • It runs in a sandboxed frame of no origin (sandbox="allow-scripts"): no cookie, no storage, no access to the page around it, and no request leaves it — the page fetches the module without credentials, checks it against integrity, and hands it over. It is given its properties and what it shows, and what it sends is taken under a name of sends only.

Its page side, as a package​

A component of your own can be a file of the application's folder — no server of yours to host it, no address to keep. The application then ships its page side the way a Reactor extension ships its browser half: in its wheel.

dial-desk/
app.py # app.custom_component("Dial", source="components/dial.js", ...)
components/dial.js # export default function draw(root, { props, send }) { ... }
loop apps package app.py # writes dist/loop-app-dial-desk/ and builds its wheel
pip install dist/loop_app_dial_desk-0.0.1-py3-none-any.whl
loop apps run app.py --web # or app.mount(api) in your own FastAPI
  • loop apps package writes the distribution loop-app-<id>: the application's module (loop_app_<id>.app, so from loop_app_dial_desk.app import app mounts it), and the files its components name under share/datalayer/reactor/extensions/loop-app-<id>/, beside a generated page-side module. It is advertised under Reactor's datalayer.reactor.extensions entry point, by the name of the application's plugin pair, loop-app-<id>, and the entry point resolves to the application's own extension, app.extension() — its plugin, the plugins it uses, its page side. A file its components name that is not in the folder is refused.
  • The server running the application finds it installed by that name — when the page asks, so a wheel installed beside a running server is found on the next load — and serves it beside the application's plugin state, as Reactor's routes serve an extension: GET <server>/api/v1/apps/<id>/plugins/frontend-extensions and GET <server>/api/v1/apps/<id>/reactor-extensions/loop-app-<id>/<path>. Nothing outside the package's directory is served, and nothing of another application.
  • The page reads it with Reactor's bootstrapExtensions and installs it into the application's extension: its page-side plugin, loop-app-<id>/page, needs the application's server plugin and says where the folder's files are served. A component of the folder is fetched from there and drawn in its sandboxed frame like any other: its module still never runs in the page.
  • On a server where its package is not installed, the component says so in its place: Its module “components/dial.js” is a file of its application's folder: it is drawn once the server running the application serves its package.

examples/packaged-page-side/ is such an application: a widget's page whose output is drawn by its folder's components/dial.js.

Messages that change​

A message sent is not final:

found = await session.send("Searching…")
rows = await run_sync(search, query) # blocking code, in a thread
await found.update(f"Found {len(rows)}.") # the same message, changed in place
note = await session.send("Read the first three.", author="Searcher")
await note.remove() # taken away from the conversation
  • await message.update(text) — or session.update(message, text) — changes what it says where the person reads it, and returns the message as it is now: the same id, the same author. A streamed message changes the same way.
  • await message.remove() — or session.remove(message) — takes it away. A removed message cannot be changed again (ValueError), and only the session that sent a message changes it.
  • author names who wrote a message, the application's name unless said: the page draws a message by anybody else under that name, and the terminal prints it before the text.

On the channel, a message changed is the same Message delivered again under its id; one removed is a Removed(message_id, session_id). MemoryChannel.messages says the conversation as it is now. Over the Session API a change is the message written again under its id, the author a reactor.ui meta op and the removal a remove op. A terminal does not write over what it printed: it prints the message again, marked ↻ edited, or says ✗ a message was removed.

Small conveniences​

Handlers are sync or async alike. Besides:

  • await run_sync(function, *args, **kwargs) runs blocking code — a file read, pandas, a client without async — in a thread, so that the session's other work goes on. An async function is refused: await it.
  • @cache keeps what a function answers for its arguments, for every session of the process. Sync or async; an async one called again while its first call runs waits for that call rather than running twice; what it raised is not kept; its arguments are hashable; fn.cache_clear() forgets.
from agent_runtimes.apps import cache, run_sync

@cache
async def prices(region: str) -> dict:
return await fetch_prices(region)

Steps​

async with session.step("Searching", kind="tool", input=query) as step:
step.output = await search(query)

A step shows the person what the application is doing while it does it. kind is run (the default), tool, model or retrieval. A step opened inside another is nested in it. Each is delivered twice: when it starts, and when it ends — with its output, or with the error that ended it.

A step that writes as it goes. Its output can be shown as it comes, rather than when it ends — the SQL as the model writes it:

async with session.step("gen_query", kind="tool", input=question) as step:
written = await step.stream(session.agent.stream(prompt))
step.output = sql_of(written)

await step.stream(pieces) takes the pieces — an async iterable such as session.agent.stream(...), a list, or one string — shows each in the step as it comes, adds it to the step's output, and returns what it wrote. Called again, it writes on. The step then ends with whatever its output is when it ends: here the query alone, without the model's fences. A step whose output is already something other than text refuses to stream, in a sentence. The chat draws the step's row with what it wrote so far, in place; the terminal writes it under the step's line; a MemoryChannel receives a StepDelta (step_id, session_id, text) for each piece, between the step's start and its end. A credential in what it writes is withheld from what is shown, as a step's output is.

The agent's tool calls are steps. Each tool the application's agent calls — through session.agent.run, session.agent.stream, or the agent answering a message itself — is a tool step named by the tool: started when the model makes the call, with its arguments as its input, so a person asked to allow it sees which call they are asked about; ended with the tool's result, or with why it failed — a call the person refused ends saying so. It is nested in the step the code was in when it called the agent. This holds in your own process (a terminal, a test, a MemoryChannel) as on a runtime, where the chat draws them. These steps are not kept again in the record: the call is there already, as a tool_call entry.

Steps are the application's chain of thought:

  • In the conversation, each is a row the chat draws as it draws a tool call — collapsed, running until it ends, its kind and input inside, then its output or its error; a nested step says the step it is in (tool step, inside Researching). Over the Session API a step is AG-UI's STEP_STARTED and STEP_FINISHED, and beside each the step's reactor.ui display that says it whole, its output patched in as it streams.
  • In the record, each step that ended is a step entry when the Appspec's record.include names actions: {id, name, kind, parent_id, input, output, error, started_at, ended_at}, its input, output and error cut and redacted as a tool call's are. They are sent when the outermost step ends.

Asking​

await session.ask(question, timeout=None) stops and asks the person, then goes on with their answer:

QuestionThe answer
a str, or TextQuestion(prompt)Text.
ChoiceQuestion(prompt, options)One of the options.
FileQuestion(prompt, accept=(), max_bytes=20 MB)An UploadedFile (name, media_type, content); accept lists media types (image/*) or extensions (.pdf).
FormQuestion(prompt, schema)A dict by field name. schema is the JSON Schema of a form, as the settings and a Form block say theirs (an object of named fields; anything else raises ValueError): the answer is checked against it, defaults filled in.

An answer that does not answer the question raises InvalidAnswer, in a sentence; no answer within timeout seconds raises AskTimeout.

On a page, the question is drawn under its words as a display (RU-4): a choice as buttons, text as a field, a form as its fields, a file as a file to choose — uploaded first, its ref the answer (RU-6); the person's press is the answer, checked against the question before your code has it. Your code has the UploadedFile whichever way it came: a file's bytes never ride in a run or a press. In the terminal it is asked as it always was.

What a person sends​

A person sends a file two ways: when the application asks (FileQuestion, or a File upload block of its page), and without being asked, attached in the composer to a message (P-21). What it takes then is said in its Appspec — the kinds, each with its largest size, and how many at once — and nothing else is sent:

app = Application.from_spec({
# …
"interface": {
"uploads": {
"kinds": [
{"type": "image/*", "max_mb": 5},
{"type": "application/pdf"},
{"type": ".csv", "max_mb": 2},
],
"max_files": 3,
},
},
})

@app.file
async def received(session: Session, files: list[UploadedFile], text: str):
names = ", ".join(file.name for file in files)
await session.send(f"Thanks for {names}. {text}")

A kind is a media type, a family (image/*, audio/*) or an extension; max_mb is 10 unless said, 25 at most; max_files 5 unless said. The composer offers a paper clip that takes those kinds and refuses the rest in a sentence before anything is read; the runtime refuses again, with 422 and the same sentence, what a client sent around the page (p.png is 6.0 MB: Photo Desk takes image/ of at most 5 MB.*). Without uploads the composer offers no attachment, and a file sent with a message is refused.

Without @app.file, the words go to @app.message (or its agent), and the files wait for what the code asks next: await session.ask(FileQuestion(...)) is answered by one at once. An application without code gives them to its agent: a text file in the message, an image, a recording or a PDF whole to its model, anything else in its sandbox when its permissions turn a shell on (see Files).

The record​

await session.record(payload, summary="", kind="output") writes an entry to the application's record and sends it at once. kind is output, tool_call, decision, check, approval, feedback, turn or step. It returns whether the application keeps entries of that kind: only those its Appspec's record.include names (app.record(include=[…])) are kept, each kind of entry under one word of it:

Entry kindKept under (record.include)What it is
turnconversationsWhat the person asked and what was answered, one turn
tool_call, stepactionsA tool the agent called; a step the code showed
decisiondecisionsWhat a rule decided of a call
checkchecksA check at its stage
approvalapprovalsWhat the person answered when asked
outputoutputsWhat session.record writes, and what an answer showed
feedbackfeedbackA thumb up or down on an answer
diff, terminaldiffs, terminalsA harness's edits whole; its terminals' output
audioaudioWhat the person said, as sound, with their consent

A session's own entries — its start, its life (paused, ended, swapped, cloned, exported, imported, titled), its rules, and what its channels were sent (notification, see Its notifications) — are kept whatever it names. The mapping is agent_runtimes.apps.record.INCLUDED_BY.

Each turn of a conversation that ends in an answer is kept by itself, as a turn entry, when record.include names conversations: what the person asked and what it answered — {"asked", "answered", "suggest_tests"}, each text redacted as every entry is and cut at 4,000 characters. A run that was stopped, or whose client went before the answer, keeps none. suggest_tests is the Appspec's record.suggest_tests when the turn was had: whether the application let its conversations be used to suggest tests to its builder. Off unless said, and only a turn kept while it was on is ever sampled into a suggested test.

Threads and feedback​

A session is a thread in the history of the person who had it: listed newest first, found by its title or by what was asked, renamed and tagged by that person (at the application's address, Your conversations). Its code can name it too (P-24):

@app.message
async def reply(session, text):
if not session.title:
session.title = f"Order {text[:40]}"
session.tags = [*session.tags, "orders"]
session.metadata["customer"] = "c-42"
...
  • session.title: its title, at most 200 characters; "" until said, and the person's first question stands for it then.
  • session.tags: a tuple; set it whole. Each tag is said once, at most 40 characters, at most 20 of them.
  • session.metadata: a dict of names and JSON values, at most 10,000 characters written. Every change is kept.

Turns. Each message a person sends is a turn of the thread — what they asked and what was answered, kept when record.include names conversations — whether the code's agent answered it or the code answered by itself (session.send with no model): the history counts it, and finds the thread by the words asked. A turn its agent answered is kept once.

Each change is a thread entry of the record, always kept and sent at once; the latest one stands. The history lists the thread under the person's own word over the code's: their title over its, its tags and theirs, their metadata over its. A value too long or too many tags raises ValueError and nothing changes.

Feedback. When record.include names feedback, a thumb up or down and a comment are offered under each answer. What a person says is kept as a feedback entry of the session — {"liked", "comment", "message", "by"}, message naming the answer by its place in the conversation (answer-2) — and then @app.feedback(session, feedback) runs; what it sends is kept in the conversation. Feedback is kept on the conversation's application — the one whose agent recorded it on that runtime, a deployment's kept always on (whose agent runs the version's app.py) as much as a configured one; a conversation the runtime did not record is refused (404, This runtime runs no application. when it runs none). Its owner lists a thread's feedback from the terminal:

loop apps threads <app> --search export --tag csv # your threads, newest first, each with its id
loop apps feedback <session> # 👎 on answer-2 by bob: Too long.

A thread is opened again from the history: the chat reads its conversation from the runtime that holds it (GET /api/v1/apps/sessions/{uid}/messages), else resumes it from its record (POST …/sessions/{uid}/resume), and goes on with it on the same thread (see the Session API). A thread whose application keeps no conversations cannot be resumed from its record.

The agent​

session.agent is the application's agent in this session (AppAgent), with its rules, its checks and its record attached:

  • await session.agent.run(prompt, **context) — the whole answer, an Answer with text and output. Keyword arguments are told to the agent for this turn (goal="…").
  • session.agent.stream(prompt, **context) — the answer's text, piece by piece.
  • Either takes model_settings= for that call alone — {"temperature": 0, "max_tokens": 500, "stop_sequences": ["```"]}, pydantic-ai's ModelSettings — over the agent's own. The model may refuse a pair: Claude takes a temperature or a top_p, not both, and says so. An agent of another framework (@app.agent) refuses model_settings in a sentence: its model is set in its own code.

Every run is a turn of the session's conversation: the history is kept from turn to turn. A rule that says ask first asks the person of this session — a ChoiceQuestion with Allow and Refuse, two buttons on the page, whose question names the tool and its arguments, then the rule (send_reply(to='ada@example.com', text='…') — Your rule “send a reply”: ask you first.) — and a refusal raises AppRuleBlockedError, saying what was not run. Every name of the API is listed on Python API, by name.

The host builds the agent. local_agent, the default, builds it in this process from the Appspec: the application's model and instructions, else its agent's, with the contexts it works under between its agent's prompt and its instructions — local_agent(app, read_organization_frames(...)) for its organization's, the catalogue's otherwise. What only a runtime brings — connections, skills, tools, a team — is refused with AppNotRunnable rather than left out, as is an application that names no model. A host may pass its own agent= factory, as the example does.

The blank agent. example-blank is the catalogue's agent for an application written in Python: a model (Claude Sonnet, through Bedrock here and ai-inference on Datalayer) and nothing else — no prompt of its own, no MCP servers, no skills, no tools. So local_agent builds it in your process, and what the agent is told is the application's instructions alone: Application(id="echo-desk", agent="example-blank:0.0.1", instructions="You are Echo Desk…") answers as Echo Desk. Its tools are the application's own (@app.tool). loop apps init --python starts a blank app.py with it. The Studio's blank application keeps example-simple, which has a decide tool and the events skill — so it runs on a runtime, not in your process.

On a Datalayer runtime the agent's model is called through ai-inference, naming the application and deployment it serves — and so is a model the page's chat names on a request (its model picker, a hosted page's agent): it is called as the agent's own model is, never directly. A runtime of your own that calls Bedrock directly needs an AWS region (AWS_REGION or AWS_DEFAULT_REGION); without one, a request naming a Bedrock model is refused before anything is asked, saying so.

An agent of your own: other frameworks​

app.agent(...) gives the application an agent written in your code, in place of its agent's (P-23): a plain function, a LangGraph graph or a LangChain runnable, a LlamaIndex agent or workflow. session.agent is then that agent, with the same run and stream, so an application with no @app.message is answered by it, and code that calls session.agent does not change. Wherever the application runs — loop apps run, a runtime, a mount — its sessions get it: it is the application plugin's contribution to loop.app.agent.

What it does becomes the session's steps (see Steps): a step for the run, named after the agent, and nested in it a step per model call and per tool call, with its input and its output — the chain of thought the chat shows, kept in the record with the tool calls, the decisions, the person's approvals and the turn.

The spec still names an agent of the catalogue (agent=): what answers where the file does not run. loop apps build marks the agent of the code — # loop:code agent langgraph: research (app.py:12) — and the Studio's Canvas shows it, locked, as the rest of what the code decides.

Each framework is an extra, so that nothing is installed for an application that does not use it:

pip install 'agent-runtimes[langgraph]' # LangGraph, and LangChain's core
pip install 'agent-runtimes[langchain]' # LangChain's core alone
pip install 'agent-runtimes[llamaindex]' # LlamaIndex's core

An app.py importing a framework that is missing is refused in one sentence naming its extra: app.py does not load. It needs LangGraph, which is not installed here: pip install 'agent-runtimes[langgraph]'.

A plain function​

@app.agent
async def answer(text: str, history: list) -> AsyncIterator[str]:
yield "You said "
yield text

Sync or async; returning the answer's text, or yielding its pieces, which are streamed as they come. Blocking code runs in a thread. Besides the prompt it is given, by name, what it asks for: session, history (the turns so far, as {"role", "content"}), instructions (the modes' and profile's), and what the code tells the agent for a turn — session.agent.run(text, goal="…") gives goal= to a function that takes it, and is refused in a sentence by one that does not.

LangChain and LangGraph​

from langgraph.prebuilt import create_react_agent

graph = create_react_agent(model, [search, send_email])
app.agent(graph, name="research", tools={"search": "read", "send_email": "send"})

The graph runs with astream_events: its model's tokens are the answer, as they come; each model call, tool call and retrieval is a step. A turn is given as {"messages": [...history, ("user", prompt)]} — the history kept by the session, or by the graph when it has a checkpointer (the session's id is its thread_id) — what the code tells the agent for a turn said as a system message before it. A LangChain runnable is given the prompt alone. Another shape is said with input=: app.agent(chain, input=lambda prompt, **context: {"question": prompt}).

Its rules decide every tool it calls through LangChain's tools. A callback handler given to each run decides each call in on_tool_start, before the tool runs: the rules, then the built-in checks and the Gates (R-06), then the checks of its code (@app.check("tool_call")). A call the rules ask about asks the person of the session; refused, the tool does not run and the turn stops with AppRuleBlockedError, its sentence the rule's.

tools= says what each tool does — read, write, send, buy, delete, publish — as @app.tool(does=...) does, and declares it in the spec, with the description and parameters the framework gives it, so that the Canvas lists it and a rule can name it. Every tool the graph is found to have (its tool nodes; a runnable's tools) must be said, and a tool said that it does not have is refused. A tool it finds as it runs and nobody said is left to the person, and so refused.

LlamaIndex​

from llama_index.core.agent.workflow import ReActAgent

agent = ReActAgent(tools=[search, send_email], llm=llm)
app.agent(agent, name="research", tools={"search": "read", "send_email": "send"})

A LlamaIndex agent (FunctionAgent, ReActAgent, an AgentWorkflow) runs with stream_events; its memory is kept from turn to turn of the session. A step per model call — what it wrote, its reasoning for a ReAct agent, as the step's output — and per tool call. Its answer comes whole when the run ends: what a LlamaIndex agent streams is its reasoning as well as its answer.

Its rules decide every tool the agent calls. Given to the application, the agent's tool calls are taken over: each is decided in its session, as a LangChain tool's is, and a call made outside a session is refused. A workflow of your own is run with run(user_msg=prompt) (or what input= returns); each event it writes to its stream is a step, and its result the answer.

What the rules see, and what they do not​

The agentIts tool calls
A LangGraph graph, a LangChain runnableDecided, each one, before it runs
A LlamaIndex agentDecided, each one, before it runs
A plain function, a LlamaIndex workflowNot seen: no rule decides them

LOOP does not see what a plain function or a workflow calls: loop apps validate says so — needs attention: Its agent is a plain function, answer: LOOP sees none of the tools it may call, so its rules decide none of them — so that the verdict never passes for what the rules never saw.

What every agent of the code gets: a credential is withheld from its answer as it streams; an answer that a Gate or a check of its code reads is given whole once they let it through, and not at all when they refuse it — a framework's model cannot be asked again, as a catalogue agent's is. What it does not get: connections, skills, backend tools, documents to answer from, a Space to save to and a team are tools LOOP gives an agent of the catalogue; an application declaring them with an agent of its code is refused, and so is @app.tool beside app.agent: give the tool to the agent itself. The modes' and profile's instructions are told to it; their model is not run — its model is its own.

Mounted into your own FastAPI​

An application runs inside a FastAPI of your own, under a path of its own (P-25):

from fastapi import FastAPI

from my_app import app # an Application, its code attached

api = FastAPI()
app.mount(api, path="/assistant")

uvicorn main:api then serves, under /assistant:

  • its page, at /assistant: the application, embedded with <datalayer-app server="…/assistant"> and its Appspec written in the element (see Embedding);
  • the runtime's own routes: the Session API at /assistant/api/v1/apps/sessions, the chat's AG-UI at /assistant/api/v1/apps/agents/<id>/ag-ui/, and the rest of agent-runtimes' routes — the runtime is mounted as it is;
  • its agent, made when your FastAPI starts, as the platform makes an application's (POST /api/v1/agents with its Appspec, in process): the runtime's lifespan runs inside yours;
  • its code: @app.message, @app.file, @app.window and the rest react to its sessions, as the catalogue's Python examples do on a runtime.
ParameterWhat it says
pathWhere it is served: /assistant unless said. A path of its own: not /, no trailing slash.
app_uidThe application as Datalayer knows it, when an embed token names it.
tokentoken(request) gives the page the visit's embed token, issued by your server for each visit (Private applications); none for a page only the machine itself opens.
embed_originWhere the embed's script is served: https://datalayer.ai unless said.
configThe runtime's ServerConfig; its defaults unless said.

Who may call is decided as on any runtime: the machine itself needs no token; anybody else sends a person's token or an embed token, from an origin the Appspec names (deployment.embedded.origins) — your page's own included. Your own routes stay yours: the mount answers its path and nothing else.

Window messages​

The application and the page it sits in talk outside the conversation:

@app.window
async def from_page(session: Session, data: dict):
if data.get("page") == "/checkout":
await session.send_window_message({"highlight": "coupon"})
const element = document.querySelector('datalayer-app');
element.addEventListener('window-message', event => {
console.log('the application says', event.detail.data);
});
await element.postWindowMessage({ page: location.pathname });

await session.send_window_message(data) — anything JSON writes — is raised on the element as its window-message event, whether its code sends it in a turn of the conversation or in answer to the page. The page posts with element.postWindowMessage(data), on the application's own server (the server attribute), once its conversation has started: its @app.window runs on it, and a session whose code has none refuses it in a sentence. A window message is not a message of the conversation: the person does not see it, and it is no turn of theirs. Beside them, the host's values and functions (host_context, host_<name>) are what its agent reads and calls, each decided by its rules (The page and the application, talking).

Channels​

A session talks to the person through a Channel: anything with two methods.

class Channel(Protocol):
async def deliver(self, event: Message | Delta | Step | StepDelta | ...) -> None: ...
async def ask(self, session_id: str, question: Question) -> Any: ...

A channel that cannot ask raises with a sentence; it never answers for the person.

MemoryChannel is the one in this process, for tests and for whoever drives an application from Python: events holds everything delivered, in order; messages the whole messages; questions everything asked; and reply(answer) answers the question being asked, or the next one.

A scene and its stage, in Python​

An application is loop.app(...) — the Application of this page. Beside it, loop.scene(...) and loop.stage(...) write the rest of the lifecycle: a scene, applications of the catalogue playing together, and its stage, where each plays and who may watch.

from agent_runtimes import loop

scene = loop.scene("desk-and-sales", "Desk & Sales", emoji="🎬", entry="desk")
scene.player("desk", app="support-desk", role="initiator", talks_to=["sales"],
name="Desk", brief="Ask Sales, and report what it answers.")
scene.player("sales", app="sales", name="Sales")

beat = scene.beat("pipeline", say="Ask Sales what a sales pipeline is.",
expect="The desk asks Sales, and reports its sentence.")
beat.asks("desk", "sales", what="what a sales pipeline is")
beat.answers("sales", "words")
beat.answers("desk", "words")
beat.rehearse("You → Desk", "Desk → Sales", "Desk: words", must_say=["pipeline"], within="3m")

loop.stage(scene, runs_in={"desk": "browser", "sales": "browser"},
positions={"desk": (0.25, 0.5), "sales": (0.75, 0.5)})

What it writes is the scene spec, loop.scene/v1 — the same one the Studio's spec editor and its Canvas write, and nothing of its own:

  • loop.scene(id, name, …) — its face (emoji, description, tags), and either a team of the catalogue, whose members are then its players, or entry, the player the audience talks to.
  • scene.player(member, …) — what it is (app, an application of the catalogue by its id), whom it asks (talks_to), its role, and how it appears (name, face, line) with its brief in this scene. On a scene of a team, a player says its persona and its brief only.
  • scene.system(server, shown_as=, holds=) and scene.setting(…) — the MCP servers on stage, and the period, the language and what the audience is told first.
  • scene.beat(id, say=| schedule=| event=, expect=, …) — a beat, after the ones before it; on the Beat it returns, asks(who, whom, …) (over is said for you: mcp to a system, a2a to a player), answers(who, kind), branch(decision, …) and rehearse(*lines, must_say=, must_not_say=, within=), the shape its transcript must take.
  • loop.stage(scene, …) — a stage is not a spec of its own: it writes the scene's parts that say where it plays — runs_in for each player (browser or runtime), positions as fractions of the box, opens_first, the transcript and inspectors, the audience (visitors, signed-in or nobody) and what an ask may cost, the account, page and addresses it is deployed with.

scene.spec is the spec agentspecs reads; what agentspecs refuses is said in agentspecs' own sentences (SceneNotPlayable), as the Studio and loop say them — a stage that puts a player at x: 5, a beat whose cue nobody answers, a player of a team that says where it runs. scene.yaml() is its text.

loop scenes rehearse scene.py # played beat by beat, here
loop scenes build scene.py --out scene.yaml # the scene spec, loop.scene/v1
loop scenes push scene.py --space <space id> # a new scene of yours, in that space
loop scenes push scene.py --scene <scene uid> # saved as a scene of yours

loop scenes push keeps it in your Space exactly as the Studio's Save scene does — an item of type scene, its spec its content — so the Studio opens it as any scene of yours: its spec editor reads the same text, its stage draws the same players. A file that writes no scene, or two, is refused in a sentence. A player is an application of the catalogue: an app.py of your own is not cast yet. The example is examples/scene-in-python/scene.py.

Chainlit's cookbook, rebuilt​

LOOP's Python flavor is meant to do everything Chainlit does, with its own API. The check is to rebuild Chainlit's own examples — its cookbook's and its documentation's — and run them: examples/chainlit-parity/ holds nine, each saying in its docstring which example it rebuilds and how each of Chainlit's calls maps to LOOP's.

Chainlit's exampleRebuilt asWhat it uses
pdf-qa, a document QAdocument_qa.pysession.ask(FileQuestion(...)), a message sent then updated, run_sync, a retrieval step, the passages as Evidence
bigquery, text to SQLtext_to_sql.pynested run and tool steps, the SQL streamed into its step (step.stream), the result as a Table, a Button and its @app.action
anthropic-functions-streaming, an assistant with toolsassistant_with_tools.py@app.tool(does="read"): declared in the spec, decided by the rules, each call a tool step; the agent loops and streams for you
openai-data-analyst, a file analysisfile_analysis.py@app.file with uploads, the figures computed in a step, a Chart
openai-whisper, a voice assistantvoice_assistant.pya voice note sent (audio/*), transcribed and spoken in steps, an AudioPlayer
copilot and window-message, a copilot in a pagecopilot.pyapp.mount into your own FastAPI, the embed as a bubble in your page, send_window_message and @app.window

And the three examples of Chainlit's documentation (its Examples pages; Cookbook and Community there are lists of links):

Chainlit's exampleRebuilt asWhat it uses
Text to SQL (examples/openai-sql)sql_from_words.pyapp.starter, model_settings for the call (temperature 0, 500 tokens, a stop sequence), the query streamed into a message as SQL
Document QA (examples/qa), conversationalconversational_qa.pyFileQuestion asked again on AskTimeout, a message updated, a retrieval step, a follow-up understood from the conversation the agent keeps, the sources in the side panel (show(..., where="panel"))
Security - PII (examples/security)pii_guard.pypersonal data found in what the person wrote, Continue or Cancel asked (ChoiceQuestion), the agent given the text anonymized

agent_runtimes/tests/test_chainlit_parity.py runs each through an AppHost with a scripted model. All but the voice assistant were also run on a real model: the document QA, the SQL, the tools, the file analysis and the three examples of the documentation in this process, the copilot under uvicorn copilot:api. All but the voice assistant run on the blank agent, example-blank, built in this process. The voice assistant has not run on a real model, nor has the copilot been seen in a browser: the embed it loads does not load yet.

What Chainlit does that LOOP does not, yet:

  • A live microphone, frame by frame. Chainlit streams the microphone to the application (on_audio_chunk); LOOP gives its code a whole turn, heard (@app.audio, Voice), never the frames as they are captured.

What the rebuilds found missing and LOOP now has: a step that streams (step.stream, Steps), a blank agent built in this process (example-blank, The agent), and each tool call of the agent as a step in this process too; and, from the documentation's Text to SQL, the model's settings for one call (model_settings).

The rebuilds use local stand-ins for the services Chainlit's examples bring: words in common for Pinecone and Chroma, SQLite for BigQuery, the application's code for the code interpreter, OpenAI's speech for ElevenLabs, a few patterns for Presidio — which finds names and places too, where the patterns find e-mails, phone numbers and cards only. Chainlit's oauth_callback is Datalayer's identity on a hosted application.

From the command line​

loop apps init starts one: a folder named by its id, with an app.py, the spec it builds and tests/test_app.py — its CI's checks, the build among them — blank or from an example of the catalogue:

loop apps init my-app --python
loop apps init interview --from customer-interview

See A new application for every option.

loop apps build writes the Appspec an app.py amounts to — its Application.spec, as YAML — printed, or to a file with --out (--force to overwrite):

loop apps build app.py
loop apps build app.py --out app.yaml

What the code decides is marked as code at the top of the spec, in comments, one line per handler, with where it is:

# Built from app.py by `loop apps build`: the file is the source; edit it there.
# What its code decides; without the file, its agent answers:
# loop:code start: opening (app.py:7)
# loop:code message: reply (app.py:12)
schema: loop.app/v1
id: echo-desk
...

An Appspec refuses fields it does not define, so the marks are comments: the built file validates, is pushed and is deployed as any Appspec is. The build is refused, with a sentence and exit code 1, when the file does not load, defines no Application or more than one, or its spec does not validate (the reasons of AppNotRunnable).

loop apps validate, loop apps run and loop apps push take an app.py as they take a spec: they build it first. deploy deploys a saved version by its id, so a Python application reaches it through push.

Where the file lives. loop apps push app.py --app <id> keeps the file itself in the application's item, beside the spec it builds — code, {"file": "app.py", "text": …} in the item's content — and versions it with the spec: a changed file is the next version, even when the spec it builds is the same, and the version left behind keeps its own file. loop apps pull <id> writes it back, app.py beside app.yaml (neither overwritten without --force). An application kept with its file is refused a spec pushed without it (… is written in Python: push its app.py, which builds this spec.). The Studio shows the file in the application's Python tab, every line numbered, and the Canvas links each thing the code decides (app.py line 23) to its line there. An application of several files keeps its app.py only.

Changed in the Studio. The Python tab is an editor too. Run in a sandbox sends the file to a runtime of the person's on Datalayer — POST /api/v1/apps/build with {"file": "app.py", "text": …} — which builds it in a Python process of its own, for at most a minute, and answers the Appspec it builds, what its code decides marked at the top ({"spec": …}), or, 422, why it does not build ({"detail": {"problems": [...]}}): a file that never ends is stopped, not the runtime's server. The Studio takes the file and that spec as its draft, saved as any change is — a changed file is the next version once the one it was at has run or shipped. Nothing is kept on the runtime. The route answers a person, never a visitor or an embed.

From a spec. An application built by spec or on the Canvas becomes one written in Python with loop apps eject app.yaml: an app.py holding its spec, ready for code, which builds it the same. One way, said before it is done; see the CLI. The Studio's Python tab has the same Eject: POST /api/v1/apps/eject with {"spec": …} answers the app.py and the spec it builds — checked to be the same, else refused with why — and the Studio saves both as the application's next version, the ones before it kept. The SPEC an ejected app.py holds says no version: the Studio numbers each version as it saves it, and the deployed version is the one the code runs as (see Its version under Application). Write a version into it only to pin the file to one version.

loop apps run app.py runs it as it runs a spec — a runtime here (--local) or on Datalayer (--cloud), configured with the built Appspec, in the terminal's renderer, the starters as its suggestions — and the terminal is also the application's AppHost:

  • In this process, through a channel to the terminal: start when the session opens, message when the application has one, an action with /action <name> [json], its page with /page [json] (its outputs said), end when the person leaves; session.ask asks at the prompt. With a message, --ask "…" runs start, then it, once.
  • On the runtime: a message the code does not take, answered by the runtime's agent with the application's rules, as for a spec.
  • Not from the terminal: settings and resume from the person, and schedules, have no way in from the terminal; and session.agent in the code is built in this process, so an application whose agent needs connections, skills or tools is refused there (see The agent). On a runtime, the Session API carries all three, and session.agent is the agent the runtime made, with its tools.

--watch builds the file again when it has changed, before the next turn: the runtime is configured with the new spec, and the session goes on with its state through the new code (AppHost.resume). A file that no longer builds is said, and the application as it was goes on. For a spec, --watch reconfigures the runtime the same way.

In a browser page, with hot reload​

loop apps run app.py --web serves the application on this machine in a browser page instead of the terminal — drawn by the embed, the same renderer as its hosted page, the Studio's Preview and a host's page — at http://127.0.0.1:8000/app (--host, --port), and opens it (--headless does not). It is served as Mounted into your own FastAPI serves one: the session API and its code under /app, its agent made on a runtime in the same process, with your own model keys.

loop apps run app.py --web --watch

With --watch, the files of its folder (.py, .yaml, .yml, .json) are read twice a second. When one changes, the application is built again:

  • taken — its next sessions run the new code, its agent is made again with the spec it now builds, and the page reloads by itself, starting a new conversation (the one before ran the code before). The terminal says Echo Desk was built again from app.py in 0.1 s.
  • refused — the file does not build, or its spec would not validate: the page says why in a band at its foot, and the terminal too (app.py was not taken: Echo Desk runs as it was., then the reasons); the application goes on as it was until a change is taken. Its id cannot change while it runs: that is another application, said in a sentence.

The embed's script comes from https://datalayer.ai/embed/ unless said otherwise: --embed-origin names another Datalayer origin, and --embed-dir serves a build of the embed from this server (agent-runtimes' dist-embed, for whoever works on the renderer). The page is yours alone: --web takes no --cloud, --ask, --a2a, --runtime or --organization, and an application run by a team is not served in a page yet.

What is verified, and what is not​

Verified. agent_runtimes/tests/test_loop_app_api.py runs every call on this page in process: declarations becoming a validated Appspec, attaching to an existing one, one handler per moment; start, message, action, settings, stop, resume, end, logout and schedule through an AppHost (an ended session taking nothing more, and reopened by resume); messages updated and removed, run_sync and cache; the application loaded as its Reactor plugin, another plugin's reaction answering in its place, switched off and on, and taken away; the agent answering, streamed, when there is no message handler; send, stream, nested steps and their errors, kept in the record; what an answer shows, checked against the catalog; ask with text, choice, file and form, wrong answers and timeouts; state per session; the record; the agent's history across turns; a rule asking the person and a refusal raising; the local agent refusing what only a runtime brings; load_application. The example above was run as printed. agent_runtimes/tests/test_apps_build.py covers build, its refusals and its marks, validate and push of an app.py, run serving one, the terminal routing to the code — an author, an edit and a removal printed, end when the person leaves — and --watch rebuilding with the session's state kept; loop apps run app.py --local --ask was run against a local server. agent_runtimes/tests/test_app_own_code.py covers @app.tool, @app.check and @app.test: declared in the spec, refused in sentences, marked by build, said by validate of a spec alone; a tool called by the agent and decided by the rules, by what it does or by a rule naming it; an answer refused and asked again, a tool call stopped, both recorded; tests run and decided by their code, from validate --tests --local. agent_runtimes/tests/test_app_steps_live.py covers a step that streams — its pieces between its start and its end, through a MemoryChannel, the terminal and the Session API's step display, text only — each tool call of the agent as a tool step, nested where it was called, ended with its result or with the refusal of the person asked, streamed or not, in this process and through a runtime's agent (agent_maker); and the blank agent built here with the application's instructions alone. The chat's drawing of a step as it streams is tested with vitest (loopStep). Both were also run on a real model (Bedrock) in this process: the SQL written into its step in 17 pieces over 0.7 s, and both tool calls of the assistant shown as steps. agent_runtimes/tests/test_app_reactor.py covers Extending with Reactor: the manifest and the extension, a contribution to another plugin's point and an extension of it, a point of its own extended by a composed plugin and read by its code, a used extension registered first and started, its commands listed and run from the composer, a used plugin's agent tool declared and run as its command, its routes mounted by app.mount and described without their endpoints, the marks of build, and examples/reactor-extensible; the example was also run on a real model (Bedrock) in this process — its agent called lookup_customer, which ran the CRM's crm.lookup — and served by loop apps run --web, its /app/health answering. agent_runtimes/tests/test_app_elements.py covers show and close — a panel, a page, inline, what is refused, the terminal — and the element displays of the Session API, drawn again on a resume. agent_runtimes/tests/test_app_page.py covers @app.page: inputs from parameters and app.input, outputs from app.output, the page in the spec and marked by build, refused in sentences when declared and when run; run through an AppHost, the terminal's /page, and the Session API's page action — a loop.page event, 422 for inputs its form refuses — and from validate --tests --local on its defaults. The page side — run as an input changes in a session of its own, its outputs shown in place — is tested with vitest (app-page-code), against a stubbed runtime; no widget page has been run against a live runtime. agent_runtimes/tests/test_app_custom_components.py covers components of your own: declared and refused in agentspecs' sentences, placed on the surface and checked, of one application only, shown inline, in a panel and with an answer, and drawing a widget's page's output; the page side — its review, its palette entry, its frame, its module fetched and checked, what it sends — is tested with vitest (app-custom-components). No custom component has been drawn in a browser by the page's own code yet. agent_runtimes/tests/test_app_packaging.py covers a page side packaged: the project and the wheel loop apps package writes, a file it names that is missing refused, and the wheel laid out as pip installs it served beside the application's plugin state — its record, its files, nothing outside its directory and nothing for another application; the page reading it with Reactor's bootstrapExtensions, installing it, and drawing a component of the folder from where it says is tested with vitest (app-packaged-page-side). agent_runtimes/tests/test_app_uploads.py covers what a person sends: the kinds, sizes and counts of uploads refused with 422 in sentences, an image given to the model whole, @app.file, and a file waiting for what the code asks. agent_runtimes/tests/test_app_mount.py covers app.mount in a FastAPI of one's own, with FastAPI's TestClient: the page, the developer's own routes, the Session API and the code under the path, the agent made through the runtime's own route, and window messages both ways. agent_runtimes/tests/test_app_frameworks.py covers app.agent: a plain function sync, async or yielding, given the session and the turn's context, a credential withheld, an answer its check refuses not given; a LangGraph agent and a LlamaIndex ReAct agent as steps, their tool calls decided — done, asked and allowed, asked and refused with the tool not run — and recorded, their tools' declarations refused when unsaid or unknown; a LlamaIndex agent's tool refused outside a session, and an AgentWorkflow deciding its agents' tools once; a workflow's events as steps; the build mark, the note of validate, and a missing framework named by its extra. Each framework runs through a fake that says its events as it does, and — where it is installed — through its own classes, a prebuilt agent on a scripted model, so that the fake is held to the framework (LangGraph 1.2, langchain-core 1.6, llama-index-core 0.14; CI runs the fakes).

Not verified. A mounted application's page in a browser — the embed's script loaded from Datalayer and talking to the mount — and a model reading an image sent: the model is scripted in the tests. A Python application reached from a browser on a cloud runtime: the Session API runs one in process in its tests (test_app_sessions.py, Report from a File taking its file from the page), not yet live. The agent is scripted in the tests; no test calls a real model through this API. No agent of another framework has run with a real model, nor on a runtime.

Not built yet. See What runs today, and what waits.