Loop Web
Loop is the Agent Runtimes workspace for the browser: a conversation, the editors the agent works in, and the sandbox the code runs on — assembled entirely from plugins. The base shell is deliberately blank: it renders one view host and some slots, and everything a person actually sees — the chat, the notebook, the prompt, the command palette — arrives as a plugin contribution. A workspace mounted with no plugins shows an empty frame, which is the honest picture of what the shell is.
Loop runs on the Datalayer reactor plugin platform: plugins declare contribution points, contribute values into points other plugins declared, render into slots, and register commands with cross-platform keybindings.
Quick start
The supported way to put Loop in a page is LoopEmbed, with the standard
plugin set from loopPlugins:
import { LoopEmbed } from '@datalayer/agent-runtimes/lib/apps';
<LoopEmbed
agentId="loop-shell"
target="browser" // code runs in the page — no server, no account
defaultEditor="none" // conversation is the canvas
floatingPrompt // draggable composer over the workspace
editorSelector // Chat / Notebook / Document, in the header
commandPalette // Ctrl-K / ⌘K over every contributed command
showHeader
/>
A host that assembles its own reactor uses the pieces directly:
import {
buildLoopReactor,
LoopWorkspace,
loopPlugins,
} from '@datalayer/agent-runtimes/lib/apps';
const reactor = buildLoopReactor(loopPlugins({ target: 'browser' }));
<LoopWorkspace reactor={reactor} serverUrl={serverUrl} agentId={agentId} />;
Architecture
The shell
LoopWorkspace — the shell component — keeps only what no plugin could do
for it:
- host the active view (
LoopViewTypecontributions) and the header, sidebar, footer, status and root slots; - mint the workspace context (
serverUrl,agentId,surfaceId, the sandbox snapshot, the prompt channel); - dispatch typed messages —
workspace.submit()runs a slash command when the text is one, and publishes to the prompt channel otherwise. Dispatch stays in the shell because only the shell sees every command every plugin contributed.
It mounts no providers of its own (no theme, no router, no query client): the entry point owns those, which is what lets the same component run as a page, inside the Datalayer app, and inside a JupyterLab panel.
Extension points
| Point | Declared by | Filled by |
|---|---|---|
LoopViewType | shell plugin | chat (chat view), a2ui — and graph, when a host adds GraphViewPlugin |
LoopEditorView | shell plugin | notebook, document — each entry appears in the segmented editor control and renders in the editor column |
LoopFrontendTool | chat plugin | notebook (cell tools), document (lexical tools) — the tools the chat hands its agent |
LoopChatComposer | chat plugin | input-prompt plugin — the chat assembles the composer's props, the contribution renders them |
LoopChatHeader | chat plugin | chat-header plugin — same shape for the conversation's title bar |
LoopChatSuggestion | chat plugin | capacity plugins — openers shown ahead of the spec's own |
LoopAgentBlueprint | agents plugin | capacity plugins — which agent the Local target creates, from which spec |
LoopChatLayout | chat plugin | page-layout plugin — the chat view assembles its parts (editors, transcript, prompt, chips, picker) and a layout arranges them; the default is the split |
LoopChatExtras | chat plugin | a host, via createChatExtrasPlugin() — a live signal carrying an error banner, the codemode toggle, MCP status, a tool-result renderer, extra frontend tools, hidePrompt, or hiddenOpeners (the spec's chips a page does not offer, by their words), for a page composing panels around a LoopEmbed |
LoopOpeningNotebook | notebook plugin | a host — the notebook the editor opens on (several cells, outputs already in them) in place of the plugin's one cell, plus the code run to prime the sandbox |
LoopOpeningDocument | document plugin | a host — the document the editor opens on, as a serialised Lexical state, in place of the plugin's own (a heading, a paragraph and a cell that has already run) |
LoopPromptPanel | input-prompt plugin | page-layout plugin — a panel hung on the composer, above or below it, inside its card so a floating prompt carries it |
LoopChatTurn | chat plugin (opens and fills it) | readers — the current turn as a live signal: the last user message, the reply as it streams, and its status; a new message replaces it. The page layout's turn panel reads it |
LoopCommand | — | every plugin; each command is a slash command, a palette entry, and (optionally) a keystroke |
LoopAgentGate | chat plugin | agents plugin — whether there is anything to talk to, and why not |
LoopNotebookToolbar / LoopDocumentToolbar items | toolbar plugins | the chat contributes its agent actions onto the editors' toolbars |
Slots (LoopSlots.header, chatHeader, promptAction, inpromptMenu, footer, status, sidebar, root) | shell | anything with chrome to add — the floating prompt uses root, the + uses promptAction |
The segmented editor control shows nothing at all until a plugin contributes an editor: an empty workspace is an empty control, not a fake one.
The page layout
loopPlugins({ pageLayout: true }) mounts LoopPageLayoutPlugin, which fills
LoopChatLayout: the notebook or the document sits on a centred sheet over a
quiet canvas, like a document application; the composer stands at the top
of the page — docked in a band at the sheet's width by default, or as a
draggable card over the canvas with pageLayoutPrompt: 'floating' — with the
openers as chips under it; and the conversation is a side
panel, closed until the header's toggle — or a host calling
openConversationPanel() — opens it. The transcript stays mounted while the
panel is closed, so the agent's tools keep running. NotebookPageAgent and
DocumentPageAgent in the examples app are the two editors on that layout.
The layout itself lives in primer-addons — PageLayout, its panel signals
and its header toggle, in @datalayer/primer-addons/lib/reactor — where any
Reactor host can mount it, on its own or as PageLayoutPlugin over slots
(page, page-band, page-panel, page-chips). The Loop's plugin is the
adapter: it hands the chat view's parts to that layout — the editors as the
page, the transcript as the panel, the composer as the band — and keeps the
turn panel, which renders the reply the way the transcript does.
The turn panel hangs the current turn on the composer (LoopPromptPanel,
fed by LoopChatTurn): the message just sent, the reply as it streams, and —
while a tool runs — what the agent is doing in the page ("Analyst is adding a
cell…", also shown at the top of the sheet). pageLayoutTurnPanel picks the
side (below by default), pageLayoutTurnPanelFooter what goes under the
reply (full footer, actions only, or none). A host can also switch the
composer's counters off live with the extras channel's showTokenUsage.
The layout follows the view. With a notebook or a document open, that editor
is the sheet and the transcript lives in the side panel. In the chat view
there is no editor, so the transcript itself lies on the sheet: same canvas,
same prompt and chips, no side panel and no turn panel — the turn is
already on the page. pageLayoutSheet (a signal, 'page' or 'panel':
the editor is on the sheet, or the transcript is) says which is the case, for
a host that wants to follow it.
The editors stay mounted out of sight, and the agent's toolset follows the
view too: with no editor on screen it keeps executeCodeInNotebook and
executeCodeInDocument, whose outputs
stream onto the transcript as surfaces, and the read tools — nothing that
would change a notebook or a document nobody is looking at.
The shell plugin is itself a thin wrapper over the generic
@datalayer/reactor-shell plugin from the reactor repository: the choice
store, the selector and the cycle command live there, and the loop supplies
what makes them the loop's — the editor point, the gating against the live
workspace, and an announcer wired to the chat's surface-request channel.
The plugin graph
LoopWorkspace (shell component — renders the view host, slots, header)
│
├── @datalayer/loop-plugin-shell owns: LoopViewType, LoopEditorView
│ │ segmented editor control, /editor ⌥⌘E
│ └──wraps──▶ @datalayer/reactor-shell the generic view point + selector + choice store
│
├── @datalayer/loop-plugin-chat ──extends shell──▶ view 'chat' → LoopViewType
│ owns: LoopFrontendTool, LoopAgentGate, LoopChatComposer, LoopChatHeader
│ renders the chosen LoopEditorView beside the transcript
│
├── @datalayer/loop-plugin-input-prompt ──extends chat──▶ composer → LoopChatComposer
│ the standard InputPrompt, rendered from the chat's assembled props
│
├── @datalayer/loop-plugin-chat-header ──extends chat──▶ title bar → LoopChatHeader
│ the standard ChatBaseHeader, same arrangement
│
├── @datalayer/loop-plugin-chat-view ──depends on──▶ loop-plugin-input-prompt
├── @datalayer/loop-plugin-notebook-view ──depends on──▶ loop-plugin-input-prompt
├── @datalayer/loop-plugin-document-view ──depends on──▶ loop-plugin-input-prompt
│ one footer icon each, switching the workspace to its view; an editor's
│ icon withdraws while its editor is not contributed
│
├── @datalayer/loop-plugin-agent-<capacity> ──extends agents──▶ LoopAgentBlueprint
│ hooks, codemode, … one folder per capacity, cast from
│ defineAgentCapacityPlugin: which agent answers, and its openers
│
├── @datalayer/loop-plugin-notebook ──depends on──▶ loop-plugin-agents
│ ├─ extends shell ▶ editor 'notebook' → LoopEditorView
│ └─ extends chat ▶ cell tools → LoopFrontendTool
│
├── @datalayer/loop-plugin-document ──depends on──▶ loop-plugin-agents
│ ├─ extends shell ▶ editor 'document' → LoopEditorView
│ └─ extends chat ▶ lexical tools → LoopFrontendTool (contributed lazily)
│
├── @datalayer/loop-plugin-agents sandbox targets, answers LoopAgentGate
├── @datalayer/loop-plugin-prompt extends chat ▶ footer + (/prompt ⌥⌘P, /new ⌥⌘R)
├── @datalayer/loop-plugin-commands ──depends on──▶ reactor-commands, @datalayer/primer-theme
├── @datalayer/primer-theme portal root + color-mode toggle (⌥⌘T)
├── @datalayer/loop-plugin-a2ui view 'a2ui' (server-rendered surfaces)
├── @datalayer/loop-plugin-agentspecs agent picker, /agents ⌥⌘A
├── @datalayer/loop-plugin-models /models ⌥⌘M
├── (@datalayer/loop-plugin-graph the plugin graph as a view, /graph ⌥⌘G — not in the preset: a host adds `GraphViewPlugin` from `./plugins/graph`)
├── @datalayer/loop-plugin-plugins-panel the sidebar of on/off switches
└── @datalayer/loop-plugin-window-frame browser-window chrome, full-screen icon
──depends on──▶ is a hard reactor dependency: it auto-mounts and cascades
on disable. extends ▶ is a contribution into a declared point: inert when
the owner is absent, and it survives the owner being disabled. The editors
deliberately extend the chat rather than depending on it — switching the
chat off must leave the notebook standing.
Fail-loud conventions
Controls that look wired but quietly do nothing are the workspace's worst failure mode, so Loop's cross-plugin channels all report whether anyone was listening:
workspace.submit()returns whether anything handled the message, with a reason to show;requestSurface(id)(a command asking for an editor) returnsfalsewhen no chat is mounted, and the command throws with words a palette can show;focusPrompt()behaves the same for/prompt.
Features
Commands, everywhere at once
Every LoopCommand contribution is at once a slash command (/notebook), a
palette entry (Ctrl-K / ⌘K), and — when it declares a keybinding — a live
keystroke. Bindings are written once, platform-agnostically (Mod is ⌘ on a
Mac, Ctrl elsewhere) and displayed per-OS in the palette. The palette binds
chords on the document capture phase, so they win over the editors' own
handlers and the browser's defaults.
| Command | Keybinding | Plugin |
|---|---|---|
| Command palette | Mod+K | commands |
/chat | Mod+Alt+K | chat |
/notebook | Mod+Alt+N | notebook |
/document | Mod+Alt+O | document |
/editor (cycle) | Mod+Alt+E | shell |
/prompt (focus) | Mod+Alt+P | prompt |
/new (reset) | Mod+Alt+R | prompt |
/graph | Mod+Alt+G | graph (only when the host adds GraphViewPlugin) |
/models | Mod+Alt+M | models |
/agents | Mod+Alt+A | agentspecs |
/surface (a2ui) | Mod+Alt+U | a2ui |
| Toggle color mode | Mod+Alt+C | primer-theme |
The composer, in three placements
The composer arrives as a plugin: the chat assembles everything it needs —
the draft, the send handler, the menus' data, the placement — and offers it
through LoopChatComposer; @datalayer/loop-plugin-input-prompt is the
standard taker, rendering the full InputPrompt with the lexical editor
(/ opens the command menu, @ mentions agents) and the session-controls
footer (tools, skills, model). Untick the plugin and the box goes; contribute
your own component to the point and the workspace types through yours, with
none of the wiring re-derived. The title bar works the same way through
LoopChatHeader and @datalayer/loop-plugin-chat-header.
promptPlacement decides where the composer stands:
bottom spans the workspace, bottom-chat sits under the transcript, and
floating wraps the same composer in a draggable card over the canvas
(loopPlugins({ floatingPrompt: true })). The footer bar is extensible
through LoopSlots.promptAction — the prompt plugin's + (start the
conversation over) arrives that way.
Views, one click from the composer
Each view is a plugin of its own — chat-view, notebook-view,
document-view — contributing one icon (with its tooltip) to the composer's
footer: the writing hand switches what stands beside the conversation
without leaving the prompt. The active view's icon shows in the accent
colour; an editor's icon withdraws entirely while its editor is not
contributed. All three require the input-prompt plugin — an icon with no
footer to sit in is a control that does not exist — so the reactor mounts
the composer with them and takes them down together. The selector's word
for the bare conversation is now Chat, not "None": with no editor
beside it, the conversation is the view. A host adds its own view icon
with defineViewPlugin from the same mould.
Capacity plugins
Every capability demonstration — hooks, codemode, guardrails, memory — is,
from the workspace's side, the same two statements: which agent answers
(an agentspec and the create-payload its server needs) and what it is
worth asking (the openers on the empty chat). defineAgentCapacityPlugin
turns those statements into a plugin: a LoopAgentBlueprint contribution
the agents plugin reads when its Local target creates the agent, and a
LoopChatSuggestion contribution the chat shows ahead of the spec's own.
export const AgentHooksPlugin = defineAgentCapacityPlugin({
key: 'hooks',
displayName: 'Agent Hooks',
description: 'Lifecycle hooks and pydantic-style tool hooks.',
specId: 'example-hooks',
suggestions: [{ text: 'Explain the hook lifecycle' }],
});
<LoopEmbed serverUrl={serverUrl} target="local" agentId={agentName}
defaultEditor="none" plugins={[AgentHooksPlugin]} />;
One folder per capacity under apps/plugins/agent-*; mounting the plugin is
the whole gesture, and the blueprint wins over the host's own localAgent
because a mounted capacity is the more specific statement about what the
workspace runs.
The shipped roster, each demonstrated by the example of the same name:
| Plugin | Spec | Capacity |
|---|---|---|
agent-a2ui | example-a2ui-agent | generated interactive A2UI surfaces |
agent-a2ui-jupyter-output | example-a2ui-jupyter-output | kernel outputs on surfaces |
agent-sandbox-* (×7) | example-sandbox-* | one per sandbox variant — eval, jupyter, docker, datalayer, colab, kaggle, monty — each pinning sandbox_variant |
agent-checkpoints | monitor-sales-kpis | pause/resume checkpointing |
agent-code-sandbox | example-full | the full code sandbox |
agent-codemode / agent-no-codemode | example-codemode / example-no-codemode | MCP tools as programmatic tools, and the raw comparison |
agent-compaction | example-monitoring | context compaction |
agent-evals | example-evals | evaluation runs |
agent-guardrails | example-guardrails | cost guardrails and policy denies |
agent-hooks | example-hooks | lifecycle and tool hooks |
agent-inference | example-inference | inference-provider switching |
agent-mcp | example-mcp | MCP servers and their tools |
agent-memory | example-memory | persistent memory |
agent-monitoring | example-monitoring | run monitoring |
agent-notifications | example-notifications | notifications |
agent-otel | example-otel | traces, logs and metrics |
agent-outputs | example-output | structured outputs |
agent-parameters | example-parameters | run parameters |
agent-skills | example-skills | skills |
agent-subagents | example-subagents | delegation to subagents |
agent-tool-approvals | example-tool-approvals | human tool approval |
agent-triggers | example-one-trigger | cron, webhook and event triggers |
Scenes without an agent — the A2UI contact card, restaurant, viewer and
components gallery — use a sibling mould, defineA2uiScenePlugin: the
canned protocol messages render as a workspace view, ordered ahead of the
chat, and the scene component stays with whoever owns the scene.
Editors that work unseen
Every editor that can run is mounted as soon as its sandbox allows — hidden, not gone. The agent's tools live in the editors (the notebook registers the cell tools, the document its lexical ones), so an unmounted editor would be an editor the agent cannot touch. Choosing an editor in the segmented control only reveals it; once mounted, an editor is never unmounted, so a sandbox hiccup cannot discard the agent's work.
Tool results in the transcript
When no editor is on screen, the chat renders what the tools did — a cell
that was inserted, updated or run appears under its tool row as a captioned
surface: the change first (the cell source, read-only), then its outputs,
bound to the live model's own output area so a run that prints as it goes
streams into the transcript. executeCodeInNotebook shows only its outputs. This
is chat machinery (ChatBase's notebookToolSurfacesId prop), not Loop
magic — any ChatBase host can turn it on.
Sandbox targets
The agents plugin makes "where the code runs" an interface: browser
(Pyodide in the page — nothing to install or sign into), local (a server
alongside), and Datalayer runtimes. The notebook binds to whichever kernel is
there and does not know the difference.
The rest of the chrome
- Theme —
@datalayer/primer-theme(from@datalayer/primer-addons/lib/reactor) creates the Primer portal root, keeps its color mode following the theme store and the OS scheme, and contributes the toggle command. - Full screen —
useWorkspaceFullScreenpromotes the whole workspace via the Fullscreen API; the chat header and the workspace header (via the window-frame plugin) share one implementation. - Window frame — browser-window chrome a page composes around the shell; its title bar is a slot.
- Agent summary — the badge saying what the workspace talks to;
agentSummary: falsefor a page that already introduces its agent. - Trial clock — the anonymous-key countdown, contributed to both headers so it survives either being hidden.
Preset options
loopPlugins(options) — every option is an off-by-default demonstration
switch except where noted:
| Option | Default | What it does |
|---|---|---|
serverUrl | — | Where the agent-runtimes service is |
target | 'browser' | Where code runs |
defaultEditor | 'notebook' | Which editor opens ('none' for the bare conversation) |
showViewSelector | true | The chat's own editor strip |
editorSelector | false | The header segmented control instead (turns the chat strip off) |
floatingPrompt | false | The composer as a draggable card (sets promptPlacement: 'floating') |
promptPlacement | 'bottom' | bottom / bottom-chat / floating |
hideChatHeader | false | No chat title bar |
agentSummary | true | The badge in the workspace header |
showAgentVariants | false | The browser/local/cloud control |
teamId, localAgent, localAgentSpec | — | Which agent answers, and what a Local target creates |
commandPalette | false | Ctrl-K over the contributed commands |
pluginsPanel, windowFrame | false | The demonstration chrome. The plugin graph is no loopPlugins option: a host that shows it adds GraphViewPlugin (./plugins/graph) beside the preset, as the Loop Workspace example does. |
Try it
The examples app ships two Loop demonstrations, both running on the browser sandbox with no account:
- Loop Workspace — everything on: the sidebar of plugin switches, the
graph (added by the example itself,
GraphViewPlugin), the browser/local/cloud control. The proof that the extension model is real: untick the chat and the prompt goes with it; untick the notebook and it leaves the editor control. - Loop Shell — the opposite pole: a blank canvas, a floating draggable
prompt, the editor selector on
Chat(the bare conversation), and thejupyterteam the landing page runs — Analyst, Reviewer, Writer, Decks, Tutor and Compactor — with the Analyst at the front door,@in the composer to reach the others, and the decks plugin mounted so a deck opens beside the conversation like the notebook and the document.
Applications
An application runs in a Loop workspace through AppRenderer: its agent, its
starters, its layout, and its presence — its face, name,
welcome and line of status — in the chat. An application can also be written
in Python: see Applications in Python.
On the browser target the agent is told what a runtime tells it: its agent's
system prompt — a Cog's from the Cogs catalogue, its Frames already in it —
then the application's own instructions, on the application's model when it
names one, else its agent's (resolveAgentspec, agentInstructions in
apps/apps). An application whose agent resolves to nothing is not run: the
chat says so in a sentence instead.
The contexts it works under
An application names its contexts — the Frames
of the catalogue, and its organization's own, whose ids start with org- — in
its context. A runtime tells its agent them after the agent's own prompt and
before the application's instructions, as the organization it belongs to
reads them (U-31, U-32):
- the version the organization's owners saved of a catalogue Frame — its rules, words and voice — in place of the catalogue's, the rest of the Frame staying the catalogue's;
- the organization's own contexts, beside them;
- the catalogue's, for an organization that changed nothing, and for an application of nobody's organization.
The organization is said with the agent: app_instance.organization_uid in
POST /api/v1/agents (AppInstance.organizationUid for AppRenderer), or
organization_uid in POST /api/v1/apps/configure. Its contexts are read
from Datalayer IAM (GET /api/iam/v1/organizations/{uid}/frames) with the
caller's token. Nothing falls back to the catalogue's: when they cannot be
read — nobody signed in, IAM unreachable or refusing — the agent is not made,
and the answer (422) says why; so is an application naming a context of an
organization's own that its organization does not have, or one with no
organization said. loop apps validate --organization <uid> checks an
org-… context against the organization's; without it, the application is
Not ready. The instant checks in a page do the same with
checkApp(app, [], { pluginsOff, organizationFrames }).
The browser target does not tell its agent the contexts yet: an application
that keeps to them runs on a runtime.
An application's layout
interface.layout says how the application is drawn, and AppRenderer draws
it on the workspace's own page-layout plugin (appLayoutOptions(app) says what
it sets), not on a layout of its own:
| Layout | What is drawn |
|---|---|
chat | The conversation alone — no page, no editor, no strip. A surface composed for a chat layout is not drawn: surfaceUnshown(app) says so in a sentence, for a builder to choose page or split. |
page | The page layout: the page on a centred sheet, the composer a card over it that starts at the bottom, the current turn under the composer. The card's display modes put the composer in the conversation panel — beside the page, over its edge or in its corner. |
split | The page layout's split (pageLayoutArrangement: 'split', SplitLayout): the conversation on the left with its composer under it, the page on the right, one hairline between them that a person drags or moves with the arrow keys, Home and End (20% to 80% for the conversation, 40% to start). |
A decision has no page of its own here, so its page or split layout keeps
the notebook and the document on the sheet or beside the conversation. A host
still wins over the application: any LoopEmbed prop it passes —
pageLayoutArrangement, editors — replaces what the layout implies.
frame draws the application in the window-frame plugin's window, titled with
its face and name. The embed's inline mode (AppEmbed, <datalayer-app>) is
AppRenderer, so an embedded application is drawn by its layout too.
The rail. With its sidebar (sidebar, what a builder reads beside its
page: its rules and approvals, its activity, its computer), an application's
workspace draws a rail (LoopEmbed's and LoopWorkspace's sidebarRail):
a narrow column of line icons on the trailing edge, one per panel, the chosen
one on a grey rounded square, and that one panel beside it. A click shows a
panel, a click on the chosen one closes the column; every panel stays mounted
while another is shown. A sidebar plugin names itself on the rail by giving
its component a rail: { label, icon } (LoopSidebarComponent); one that
does not is shown by its id under a generic icon.
A pane opening. The split's page, the rail's panel and the page layout's
conversation panel open at the theme's pace — --theme-motion-pane and
--theme-motion-easing (paneOpening; primer-addons' panelOpening) — which
only the loop theme sets to a duration: nothing moves in another theme, nor
when the person asked for reduced motion.
Tested in app-layout.test.tsx (the options per layout, the split's
contribution, the hairline by key and pointer, the embed's inline mode) and
sidebar-rail.test.tsx (the rail, its panels, the pane's motion).
An application's page
A chat, a widget or a worker runs as its conversation. When it has a page —
a page or split layout — AppRenderer mounts the app-page plugin (defineAppPagePlugin), which
contributes the page as the editor beside the chat (LoopEditorView), in place
of the notebook and the document. It is drawn by the workspace's own A2UI
renderer (InlineSurface, the one that draws surfaces in the transcript); an
application with a page layout and no surface composed gets the default page
of its kind, with the blocks the workspace's plugins contribute (see
The blocks a page draws). A
decision's page is its host's, drawn as the one view of a workspace without a
conversation (A decision).
The page's data model is published from the conversation, by path, and what a
block writes there reaches the application through the chat — a message sent,
the answer stopped, the conversation started over. Nothing else: there is no
server call behind a button. APP_KIND_PATHS says it per kind, each entry with
its words, and appKindPaths(app) adds the application's settings:
| Kind | Publishes | Takes | Buttons |
|---|---|---|---|
| chat | /app, /question, /answer, /status, /messages, /inputs/<id> | /draft, /inputs/<id> | send, stop, new |
| widget | /app, /output, /status, /inputs/<id> | /files, /inputs/<id> | run, upload, stop |
| worker | /app, /goal, /activity, /report, /status | /draft | send, stop |
/status is where the answer stands, in a word (Ready, Thinking, Answering,
Answered, Stopped). /inputs/<id> is a field of interface.settings, the
JSON Schema of the settings' form (C-16). A page with no surface composed
draws them as one Form block, with @datalayer/primer-rjsf: a form without an
action has no button and writes its values at /inputs as they are filled, for
the next run. A ChoicePicker a builder placed at a setting holds a list, the
first value sent. send sends the
context's message, or what is written at /draft, and for a chat adds the
settings in words when they changed since the last message; run sends every
value written under /inputs, one Label: value per line.
/messages is the whole conversation, in order, as the Chat component draws
it: {role: 'user' | 'assistant', text}, and {role: 'tool', name, args, result} for a tool called — empty again when the chat starts over. The chat
plugin keeps it beside the turn (LoopChatTurn's conversation), from every
change of the chat's items, the reply as it streams included. A Chat block
bound to it (messages: {path: /messages}, message: {path: /draft}, its
action send) is the application's own conversation: what is written in it is
sent through the chat's own send, as the page's composer sends, and begins a
turn as a message typed does, so /status, /answer and a widget's /output
follow what a button or a block sent.
/files is what a FileUpload block gives, [{name, type, size, data_url}],
held in the page. run sends the files there with the inputs, and upload, a
File upload's own action, runs at once on the files of its context (else those
at /files): each is uploaded to the application's runtime first, and its ref
goes to the application's session with the chat's run (RU-6: no
bytes in the run)
(forwardedProps.loop, see Session API), which puts each in
the sandbox of an application with a shell, whatever its type and size, gives
a text file in the message to one without, and refuses anything else in a
sentence. What is not a list of files, or a file over MAX_FILE_BYTES
(25 MB), is refused on the page and nothing is sent.
{"id": "file", "component": "FileUpload", "label": "The CSV", "accept": [".csv"],
"files": {"path": "/files"}}
A setting reaches a written application in the words of its message, and
every application's session as a value (forwardedProps.loop.settings). A button the kind does not handle, or a send with nothing
written, is refused in a sentence on the page; a tree the A2UI catalog refuses
is said where it would have been drawn.
Checks
An application's checks run beside its rules, enforced by the runtime
(apps/guards.py, AppChecksCapability; R-06). agentspecs
declares Guards and Gates; this runtime executes them. Three moments:
-
At a session's start (
preflight), once per session, before its model is first asked — after every capability'sbefore_run, so the record is open. The built-in check first: nothing shaped like a credential in what the application tells its model (its instructions, its modes', its profiles'); one found stops the session — The instructions hold a GitHub token: nothing sensitive is given to its model. — since R-19 would withhold it anyway and a secret written there is a fault of the Appspec. Then each preflight Guard it names underchecks.guards, run on its Appspec:- the Required Frame Guard (
frames_missing): every context undercontextresolves in the catalogue and is enabled; an organization's own (org-…) is one its organization has, which the agent was given at its creation (app_capabilities(organization=…)); - the Permission Guard (
permission_denied): what the application does needs permissions its guardrail (default-platform-user) grants —read:datafor its connections, documents and Spaces;write:datafor a connection or a Space granted to write, or a tool that writes or deletes;execute:codefor a computer with a shell;access:internetfor one that browses;send:emailfor a tool that sends;deploy:productionfor one that publishes (permissions_needed); - the Data Source Authorization Guard (
unauthorized_source): its connections' servers are among its guardrail'sallowed_systems(data-engineering-power-user: postgresql, mongodb, s3, kafka), its documents and Spaces among the allowed objects and not among the denied, and no word of its instructions or of what the session was opened with matches a denied field (*SSN*,*Bank*,*IBAN*); an empty allowed list allows every one.
Every Guard's verdict is a
checkentry of the record (R-07) —stage: preflight,guard,passed,gatewhen a Gate read it (configuration-check), and its sentence. A Guard that fails stops the session with that sentence, a Gate naming it or not: there is nothing to send back before the first turn, and nothing is warned about and let run. Its next turn is checked, and stopped, again. A Python application's session preflights the same way before its code is first asked. - the Required Frame Guard (
-
On every tool call (
in_flight): the built-in check stops a call whose arguments hold a credential; the Sensitive Data Guard adds its guardrail's denied fields, the Tool Use Policy Guard a tool the application does not declare; the Gates decide —sensitive-data-stopstops,tool-violation-retrysends the step back, one that asks a person asks through the tool-approval path. What stops a step is acheckentry (stage: in_flight,guard: built-inor the Guard's id,passed: false). -
On the answer (
post_run): a credential in it has the model answer again without it; what streams is withheld as it streams.
A Guard judged by a Cog or a person is not run: loop apps validate and the
Studio's instant checks say so (Needs attention), and AppChecks.unexecuted
is logged at each run's start — never passed in silence.
Credentials
A credential is never shown to the model. A connection uses a stored secret through its tool: the runtime holds the secret and the tool sends it where it goes — the header an MCP server is started with, the account's API key a toolset signs with, the token of the gateway a connection reaches in the person's name. The model only ever reads what the tool did.
What the runtime holds as a secret. Every environment variable whose name
says it is one — a part of its name is KEY, TOKEN, SECRET, PASSWORD,
PAT, CREDENTIALS, COOKIE or AUTHORIZATION (EARTHDATA_TOKEN,
DATALAYER_APP_USER_SECRET_<UID>), unless its last word says where or who it
is (…_URL, …_ID, …_USERNAME, …_PATH). And every value given to it as
one: what a ${VAR} expands to in an MCP server's header or argument that
sends a credential (Authorization: Bearer ${…}), whatever the variable is
called; the secrets a configure gives an MCP server or the sandbox's kernel;
the sandbox's token; a deployment's user secret; the token of an agent node.
A value shorter than eight characters, or a path, is never withheld by its
value.
What the model cannot see. Each agent the runtime makes — the create
route's, an application's, a subagent's — carries
CredentialsWithheldCapability, last of its capabilities
(agent_runtimes.guardrails.credentials). A held secret, and anything shaped
like a credential (a private key, an AWS, GitHub, Slack or Google key, an API
key, a signed token), is replaced by [a credential, withheld] in:
- a tool's result and its error — before any other capability, the record,
the stream to the browser or the span of the call reads it; a tool that
fails with one fails with a
ToolErrorWithheldthat says it withheld, the original neither chained nor logged; - a tool's description and its parameters;
- every request to the model — its instructions, the prompt, what the page
answered
host_context, a retry — the conversation kept as it was sent.
What is never kept or said with one. The record (every entry, whoever
wrote it), a session's steps, the spans of a scene and of a tool call, the
sentence a failed turn or A2A task is said in, an MCP server's failure (as
the routes answer it and as it is logged), and the config the MCP routes
answer: an MCP server's arguments and environment keep their ${VAR} until
the server starts.
Only the secrets its specs declare are given (decided 2026-10-07). A
runtime is never given the account's whole set of secrets: it is given those
its specs declare, read off them by
agent_runtimes.guardrails.declared_secrets — each MCP server it reaches (the
agent's mcp_servers, an application's connections): its envvars and
every ${VAR} its command line, environment or address names; each skill its
agent runs: its envvars; a subagent it is by ref, the same; and a
deployment that takes a signed user, its DATALAYER_APP_USER_SECRET_<UID>;
and what an MCP server's sandbox_envvars name — what the code its tools
write reads in the sandbox, Earthdata's download script reading
EARTHDATA_USERNAME and EARTHDATA_PASSWORD — given to the sandbox when the
account has them, never to the server, never required. The runtime itself needs none of the account's secrets
(PLATFORM_RUNTIME_SECRETS is empty): it calls its models with its
ai-inference token and acts with the person's token, each given apart.
DATALAYER_API_KEY is given only when a server or a skill declares it — the
Earthdata connection's bearer does.
| Who | Gets |
|---|---|
| the runtime's process | every declared secret |
| an MCP server | its own, and no other server's |
| the code sandbox (the kernel the model's code runs in) | the skills' and the servers' sandbox_envvars alone: a server's credential stays with the server |
| a person's notebook (a runtime with no agentspec and no application) | its kernel keeps the account's secrets (decided 2026-10-07) |
| anything else | nothing: a secret no spec declares is given to nothing |
An application's runtime is launched with its Appspec. Whoever launches
one for an application sends its Appspec with the launch —
RuntimesClient.create(app_spec=…) refuses app_uid without it, and so does
the Operator (422); loop apps run --cloud, a deployment kept on its
runtime and a scheduled or event session all send it. The Operator hands it
to the companion's start hooks, the companion declares it with the spec and
hands it on (launch_app_spec) to configure-from-spec or
mcp-servers/start, which remember it: the runtime holds the secrets its
connections declare before the application's agent is made, every later
configure declares them too, and a missing one is refused in the sentence
above, its agent with it.
The browser launches the same way. The Studio's Preview and an
application's hosted page, signed in, allocate its runtime through
useAgentRuntimes with appLaunch — its Appspec, and the saved application
and deployment when there are — which DatalayerAgentBridge reads from the
payload its agent is created with (appLaunchOf). createRuntime sends
them as app_spec, app_uid and deployment_uid, and refuses an
application without its Appspec, or a deployment without its application,
before asking anything. A preview of an application not saved yet sends its
Appspec alone. An application's runtime is always its own: the hook never
looks for a running one to reuse for it.
A runtime you attach to (loop apps run … --cloud -r <runtime>) was not
launched for the application, and secrets reach a runtime only when it is
launched, from its companion. The application is still configured there: if
the runtime holds every secret its connections need, it runs. If not, the
configure refuses in the sentence above, and loop adds runtime-9 was not
launched for Web research: a runtime is given an application's secrets when
it is launched for it. Run it without --runtime to launch one for it. Without
-r, loop apps run --cloud does not offer you a running runtime first, as
loop --cloud does for an agent: it launches one for the application.
Before it gives anything, the pod companion asks
POST /api/v1/agents/declared-secrets (the spec's names, answered with names
only) and passes on those of the account's secrets, no other; when the runtime
does not answer, it passes on none. configure-from-spec (and so
/apps/configure) keeps to them again on receipt: an undeclared name it is
sent is dropped and logged by name, one a spec declared before and no longer
declares is unset, and mcp-servers/start keeps to what its running agents
declare. Fail fast: a spec whose server or skill needs a secret that is
not there is refused with 422, a sentence per secret — Crop monitoring is
not set up: the MCP server earthdata needs DATALAYER_API_KEY, which this
runtime was not given… — and loop apps run --cloud says that sentence and
stops the runtime instead of waiting; nothing falls back to all the secrets.
A runtime with no agentspec and no application is a person's notebook: its
kernel keeps the account's secrets. A configure that takes a name away also
unsets it in every live kernel (CodeSandboxManager.withdraw_env_vars runs
an os.environ.pop there, by name), and a kernel started later is not given
it.
What it cannot do. Code the model writes runs in a sandbox that has the skills' declared secrets in its environment, because a skill's script needs them. A secret printed as it is, is withheld; one printed changed — reversed, encoded — is not, since no value matches it. An application's answer is checked for credentials by its built-in check too (R-06).
Extending Loop
A host plugin is ordinary reactor code. One that adds an editor, its tools,
and a command — the contribution points are imported from lib/apps/core,
which lib/apps does not re-export:
import { contribution, definePlugin } from '@datalayer/reactor';
import {
LoopCommand,
LoopEditorView,
LoopFrontendTool,
requestSurface,
} from '@datalayer/agent-runtimes/lib/apps/core';
export const MyEditorPlugin = definePlugin({
name: '@me/loop-plugin-spreadsheet',
contributes: [
contribution(LoopEditorView, {
surfaceId: 'spreadsheet',
title: 'Spreadsheet',
order: 30,
load: () => import('./SpreadsheetView'),
}, { id: 'spreadsheet' }),
contribution(LoopFrontendTool, {
id: 'spreadsheet-tools',
tools: workspace => createSpreadsheetTools(workspace.surfaceId),
}, { id: 'spreadsheet-tools' }),
contribution(LoopCommand, {
name: 'spreadsheet',
description: 'Open the spreadsheet beside the chat',
group: 'Open',
keybinding: 'Mod+Alt+S',
run: async ({ workspace }) => {
workspace.setActiveViewType('chat');
if (!requestSurface('spreadsheet')) {
throw new Error('No chat is on screen to open the spreadsheet beside.');
}
},
}, { id: 'spreadsheet' }),
],
});
Mount it beside the preset: LoopEmbed takes plugins={[MyEditorPlugin]},
and buildLoopReactor([...loopPlugins(options), MyEditorPlugin]) does the
same without the embed.