Skip to main content

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 (LoopViewType contributions) 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​

PointDeclared byFilled by
LoopViewTypeshell pluginchat (chat view), a2ui — and graph, when a host adds GraphViewPlugin
LoopEditorViewshell pluginnotebook, document — each entry appears in the segmented editor control and renders in the editor column
LoopFrontendToolchat pluginnotebook (cell tools), document (lexical tools) — the tools the chat hands its agent
LoopChatComposerchat plugininput-prompt plugin — the chat assembles the composer's props, the contribution renders them
LoopChatHeaderchat pluginchat-header plugin — same shape for the conversation's title bar
LoopChatSuggestionchat plugincapacity plugins — openers shown ahead of the spec's own
LoopAgentBlueprintagents plugincapacity plugins — which agent the Local target creates, from which spec
LoopChatLayoutchat pluginpage-layout plugin — the chat view assembles its parts (editors, transcript, prompt, chips, picker) and a layout arranges them; the default is the split
LoopChatExtraschat plugina 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
LoopOpeningNotebooknotebook plugina 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
LoopOpeningDocumentdocument plugina 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)
LoopPromptPanelinput-prompt pluginpage-layout plugin — a panel hung on the composer, above or below it, inside its card so a floating prompt carries it
LoopChatTurnchat 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
LoopAgentGatechat pluginagents plugin — whether there is anything to talk to, and why not
LoopNotebookToolbar / LoopDocumentToolbar itemstoolbar pluginsthe chat contributes its agent actions onto the editors' toolbars
Slots (LoopSlots.header, chatHeader, promptAction, inpromptMenu, footer, status, sidebar, root)shellanything 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) returns false when 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.

CommandKeybindingPlugin
Command paletteMod+Kcommands
/chatMod+Alt+Kchat
/notebookMod+Alt+Nnotebook
/documentMod+Alt+Odocument
/editor (cycle)Mod+Alt+Eshell
/prompt (focus)Mod+Alt+Pprompt
/new (reset)Mod+Alt+Rprompt
/graphMod+Alt+Ggraph (only when the host adds GraphViewPlugin)
/modelsMod+Alt+Mmodels
/agentsMod+Alt+Aagentspecs
/surface (a2ui)Mod+Alt+Ua2ui
Toggle color modeMod+Alt+Cprimer-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:

PluginSpecCapacity
agent-a2uiexample-a2ui-agentgenerated interactive A2UI surfaces
agent-a2ui-jupyter-outputexample-a2ui-jupyter-outputkernel outputs on surfaces
agent-sandbox-* (×7)example-sandbox-*one per sandbox variant — eval, jupyter, docker, datalayer, colab, kaggle, monty — each pinning sandbox_variant
agent-checkpointsmonitor-sales-kpispause/resume checkpointing
agent-code-sandboxexample-fullthe full code sandbox
agent-codemode / agent-no-codemodeexample-codemode / example-no-codemodeMCP tools as programmatic tools, and the raw comparison
agent-compactionexample-monitoringcontext compaction
agent-evalsexample-evalsevaluation runs
agent-guardrailsexample-guardrailscost guardrails and policy denies
agent-hooksexample-hookslifecycle and tool hooks
agent-inferenceexample-inferenceinference-provider switching
agent-mcpexample-mcpMCP servers and their tools
agent-memoryexample-memorypersistent memory
agent-monitoringexample-monitoringrun monitoring
agent-notificationsexample-notificationsnotifications
agent-otelexample-oteltraces, logs and metrics
agent-outputsexample-outputstructured outputs
agent-parametersexample-parametersrun parameters
agent-skillsexample-skillsskills
agent-subagentsexample-subagentsdelegation to subagents
agent-tool-approvalsexample-tool-approvalshuman tool approval
agent-triggersexample-one-triggercron, 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 — useWorkspaceFullScreen promotes 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: false for 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:

OptionDefaultWhat it does
serverUrl—Where the agent-runtimes service is
target'browser'Where code runs
defaultEditor'notebook'Which editor opens ('none' for the bare conversation)
showViewSelectortrueThe chat's own editor strip
editorSelectorfalseThe header segmented control instead (turns the chat strip off)
floatingPromptfalseThe composer as a draggable card (sets promptPlacement: 'floating')
promptPlacement'bottom'bottom / bottom-chat / floating
hideChatHeaderfalseNo chat title bar
agentSummarytrueThe badge in the workspace header
showAgentVariantsfalseThe browser/local/cloud control
teamId, localAgent, localAgentSpec—Which agent answers, and what a Local target creates
commandPalettefalseCtrl-K over the contributed commands
pluginsPanel, windowFramefalseThe 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 the jupyter team 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:

LayoutWhat is drawn
chatThe 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.
pageThe 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.
splitThe 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:

KindPublishesTakesButtons
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/draftsend, 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's before_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 under checks.guards, run on its Appspec:

    • the Required Frame Guard (frames_missing): every context under context resolves 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:data for its connections, documents and Spaces; write:data for a connection or a Space granted to write, or a tool that writes or deletes; execute:code for a computer with a shell; access:internet for one that browses; send:email for a tool that sends; deploy:production for one that publishes (permissions_needed);
    • the Data Source Authorization Guard (unauthorized_source): its connections' servers are among its guardrail's allowed_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 check entry of the record (R-07) — stage: preflight, guard, passed, gate when 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.

  • 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-stop stops, tool-violation-retry sends the step back, one that asks a person asks through the tool-approval path. What stops a step is a check entry (stage: in_flight, guard: built-in or 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 ToolErrorWithheld that 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.

WhoGets
the runtime's processevery declared secret
an MCP serverits 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 elsenothing: 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.