Agent Runtime · In Development

Legate

A plugin that is an agent

An agent that lives on one machine. The built-in LLM and MCP nodes run inside the platform and reach outward — they call a model, and a tool call becomes an outbound port for the flow to act on. Legate moves that shape somewhere else: it is installed on a build server, a jump host, a box inside a segment the platform cannot reach, and a workflow hands it an instruction rather than a script.

It reads, writes, searches and — when the profile allows — runs commands there, decides for itself which of those steps the instruction needs, and reports back what it did.

One node call is one unit of work.Not one model turn.
01 — The direction change

The platform reaches out. Legate is already there.

A plugin node normally runs somewhere the platform can see and calls outward on the flow's behalf. That works until the work has to happen on a specific box — one with the checkout on its disk, the credentials in its environment, or a firewall between it and everything else.

Legate inverts the arrangement. You install it on that machine, provision it as a plugin in a space, and it appears on the canvas as a node like any other. What crosses the wire is an instruction and a result — not a shell script, and not a model turn.

The built-in nodes

Inside, reaching out

An LLM or MCP node runs in the platform. A tool call becomes an outbound port, and the flow decides what to do with it. The platform owns the loop.

Legate

Outside, already in place

The loop runs on the target machine. The flow states the goal; the agent chooses the steps and reports the work. One call, one unit of work.

A workflow hands it an instruction, not a script — and gets back what was actually done, on the machine where doing it was possible.
02 — The design

Both sides of MCP, in one process

The capabilities of the machine are published as this plugin's own MCP server, and consumed through an ordinary MCP client over an in-process transport — a function call that happens to speak MCP. No socket is involved: the agent reaches this machine through a function call, not a port.

                    ┌──────────────────── legate process ───────────────────┐
  flow ── run ──────▶ agent loop ──── mcp client ──┬── in-process ──▶ host MCP server
                    │      ▲                       │    fs_* sh_* host_* skills_* pkg_* net_*
                    │      └── tool results ───────┤                        │
                    └──────────────────────────────┼────────────────────────┘
                                                   └── streamable-http / sse / stdio ──▶ any MCP server

The model sees one set of tools

Reading a local file and calling a vendor's hosted MCP server are the same kind of thing to the model, and to the loop that dispatches. There is no second mechanism to learn and no second failure mode to debug.

It doesn't grow a tool for everything

Point a node at an MCP server and its tools join the built-in ones, with no rebuild. The plugin's capability surface is something you configure, not something you wait for.

The optional operator panel is the one thing in this binary that listens — and it is off unless you configure it.

03 — The toolbelt

What the machine offers

Six families, published by the in-process host server. Files and search are always there; everything that can change the machine is behind a switch.

fs_*Files — always on
fs_read
Read a text file, with line numbers, optionally a slice
fs_write
Create or overwrite a file, creating parents
fs_edit
Replace an exact string; refuses an ambiguous match
fs_list
List a directory, optionally a few levels deep
fs_glob
Find files by pattern (**/*_test.go)
fs_grep
Search contents by RE2 regular expression
fs_delete
Delete a file or an empty directory
host_*The box itself
host_info
Hostname, OS, user, workspace, and this node's own limits
host_env
Environment variables, credential-looking values masked
host_disk
Free and total disk space
host_net
Interfaces, addresses, outbound source address
skills_*Only when a skill is enabled
skills_list
The procedures installed on this machine
skills_load
Read one skill in full, before acting on what it covers
skills_file
Read a script or template bundled with a skill
sh_* pkg_* net_*Opt-in
sh_exec
Run a command — off unless the profile enables it
sh_which
Check whether a program is installed
pkg_install
Install packages — off unless the profile enables it
net_fetch
Download a file over https into the workspace — off by default
A model is never offered a facility that would answer "not configured here". From inside a run that reads as a broken tool rather than an empty shelf.
04 — Skills

Knowledge that belongs to the box, not the flow

How deployments are rolled here. Where backups land. What "restart the API" means on this host. Without somewhere to put that, it gets retyped into the instruction field of every node that might need it — and a legate installed as a devops agent knows no more on its tenth run than on its first.

The format is the one Anthropic's Agent Skills use, so a directory from a public skills repository works unchanged.

$LEGATE_STATE_DIR/skills/postgres-backup/
  SKILL.md        # --- name, description, tags, version --- then the procedure
  .legate.json    # where it came from: source, commit, installed_at, enabled
  scripts/…       # optional files the procedure refers to

Only names and descriptions reach the prompt

Every enabled skill costs one line in the system prompt of every run on this machine. The body is loaded through skills_load when the model decides a skill applies. Thirty procedures cost thirty lines, not thirty documents — and the choice of which to read is made by the model with the task in front of it.

Which makes the description load-bearing

It is the entire basis on which a skill is chosen, so it must say when to use this, not what it is called. A description that only names the skill is the usual reason one is never loaded — and the panel's What the agent sees page exists to make that visible.

Outside the workspace, deliberately

Skills live outside workspace_root. Inside it, fs_write would let one instruction rewrite the standing orders of every run that came after it.

Imports arrive switched off

Anything the panel fetches from a repository is disabled on arrival. It is text nobody has read yet, and it is about to become a standing instruction to an agent that can write files here.

05 — Containment

What holds the agent in

01

The workspace jail

Every path a tool is handed is resolved through symlinks and must land under workspace_root. A relative path is relative to it; an absolute path is accepted only if it is already inside; a symlink planted in the workspace cannot be used to write outside it.

02

The shell is opt-in

With it off the agent still reads, writes, searches and edits files — it just cannot run programs.

03

The allowlist changes how commands run

When it is non-empty, a command is parsed and executed directly, with no shell — no pipes, no &&, no redirection — and a command containing shell metacharacters is refused. An allowlist can only vouch for the program it can see, so git status && curl … is rejected rather than half-checked. With the list empty and the shell on, commands go to sh -c and the agent can run anything this process's user can.

04

Bounded output

Each tool result is capped before it reaches the model, so one broad search cannot fill the context window.

05

Bounded turns

The loop stops at max_turns and commits what it did.

The workspace root and the process's own OS user are the real boundary. Run the plugin as a user that owns nothing you would mind an instruction touching.
06 — Provisioning

Two narrow verbs instead of one wide one

A legate sent to a machine sometimes finds it is not equipped for the job. Installing a package was always possible — it is apt-get install git through sh_exec — but reaching it means turning the whole shell on, with no allowlist, because a package-manager invocation is not something an allowlist of program names can usefully vouch for. These grant one verb each.

pkg_install

Runs this machine's package manager — apt, dnf, yum, zypper, apk, pacman, brew — for named packages. No shell, no flags, names checked against a pattern and against install_allowlist.

net_fetch

Downloads over https into the workspace. What arrives is written without the executable bit, into a directory that is not on PATH. Plain http is refused, and so is a redirect off https.

Scope the sudo, don't run as root

Installing needs root, and root dissolves the workspace jail for sh_exec. Give the process a sudoers entry scoped to the package manager rather than running the whole plugin as root. sudo -n is used, so a machine that has not been set up for this fails immediately instead of hanging on a prompt nobody will answer.

Fetching and running stay apart

That separation is the whole of how curl … | sh is avoided: bytes arrive as data, and something else has to decide to run them.

07 — The panel

One page about one machine

Off by default. Set LEGATE_PANEL_ADDR and this binary serves the skills installed here, what is running right now, and the history of what has run. It exists because the platform cannot do those things from where it sits: it cannot list this box's directories, cannot take a file meant for this disk, and cannot see what this agent has been doing.

LEGATE_STATE_DIR=/var/lib/legate
LEGATE_PANEL_ADDR=127.0.0.1:7788        # unset = no panel at all
LEGATE_PANEL_TOKEN=                     # required for any non-loopback address
LEGATE_STATS_RETENTION_DAYS=30
Binding wide without a token fails at start-up

The panel can install skills and read this machine's history, on a server chosen for being somewhere the platform cannot reach. So the safe default is an ssh tunnel to 127.0.0.1, and exposing it has to be an explicit decision with a token behind it.

It shows and configures. It does not start runs

Starting work stays with the flow — which is where the platform's own record of what was asked for lives.

08 — On the canvas

Two actions, and an output a flow can branch on

MethodTitlePurpose
legate.agent.runRun InstructionThe agent: prompt in, work done on this machine, outcome out.
legate.tool.callCall ToolOne tool, arguments from the flow, no model. Same sandbox.

Plus the meta RPCs the form needs: legate.meta.ping validates settings, legate.meta.tools lists what this deployment can see, and legate.meta.tools.fill / legate.meta.tool.pick drive the buttons.

{
  "reply": "Wrote report.md and verified it parses.",
  "turns": 4,
  "stopped": "answered",              // or "max_turns"
  "host": "build-02",                 // which machine this came from
  "workspace": "/srv/agent/work",
  "tools_used": ["fs_glob", "fs_read", "fs_write"],
  "skills_used": ["log-triage"],      // which of this box's procedures it followed
  "usage": {"input_tokens": 4210, "output_tokens": 612},
  "retries": 0,                       // model calls that had to be made again
  "calls": [ /* every call, in order, with its result */ ],
  "messages": [ /* the conversation, resumed by the next run */ ]
}
Branch on what it did, not on prose about it

A flow can route on tools_used or calls — the record of the work — instead of parsing a sentence describing it.

reply and messages are different sizes on purpose

reply is the summary a later node should read; messages is the audit, and it grows with the work. Feeding messages into the next agent's instruction is what turns a multi-agent flow into one that spends its context on transcripts.

Built for more than one legate

host and skills_used are there for the case where one context collects the work of several. Without them a central context cannot tell which box a result came from, nor whether an agent followed a named procedure or improvised — and "the deploy failed" is a different fact depending on where.

09 — Instructions in a loop

The instruction is a template

{{$.some.path}} reads the flow context and {{$this}} / {{$this.field}} read the slice of context this run was handed. Scope the node to an array and it runs once per element.

ContextNode scopeInstruction field
{"tasks": [{"goal": "…"}, {"goal": "…"}]}$.tasks[*]{{$this.goal}}
It remembers earlier passes by default

Later tasks know the ground the earlier ones covered. Turn on Start fresh each run when the tasks are unrelated, and give each pass its own directory with a Subdirectory of run-{{$this.id}}.

Leave outbound-port routing off inside a loop

Route outbound ports by tool used filters outbound edges by tag, and a loop's untagged return edge would be filtered away.

10 — When the provider has a bad minute

A node on a build server has nobody watching it

Providers stall. The same request that answers in thirteen seconds can hang for a quarter of an hour, with nothing on the wire to say which it will be, and a gateway in front of the model may hang up on it with a 502 long before the model gives up. So every model call has a deadline (request_timeout_s, default 180) and a failed one is tried again (max_retries, default 3, jittered exponential backoff).

1m52s

measured time to first token on a real endpoint that then streamed normally — so a two-minute deadline cuts off calls that were about to succeed

→ 180s
~220s

the idle timeout commonly sitting in front of a model — past that the intermediary answers first with a 502 and our own deadline never fires at all

Both bounds are measured. 180 sits between the two.

The retry is of the turn, not of the run

The conversation is untouched between attempts, so attempt two resumes exactly where attempt one was — and the sixteen turns of work already done on that machine are not thrown away because the seventeenth call met a bad minute. retries is reported and kept in the history, because a run that quietly retried six times is a run whose provider is in trouble, and that is worth seeing before it becomes a run that fails.

A refusal is not retried

A 400, 401, 403 or 404 is a verdict on the request itself: sending it again produces the same verdict more slowly and buries the real problem under a retry count. Stalls, 5xx, rate limits and dropped connections are the provider having a moment — those are worth another go.

Work done before a failure survives it

The turns, the tool calls, the tokens and the skills loaded are all recorded even when the run ends badly. A history that drops them does not merely lose data — it tells the next agent that reads it that nothing happened.

Every model call is streamed. That is what keeps bytes moving on the wire during a long answer — a buffered request is silent for the prefill and the whole generation, which is long enough for something in the path to cut it off. It also makes a stall legible:

legate: turn 6/24 attempt 1 — nothing received after 15s
legate: turn 6/24 attempt 1 — nothing received after 30s
legate: turn 6/24 attempt 1 — first token after 1m52.62s

Those separate "the request was never answered" from "the model is slow to start" from "the answer began and died" — three different faults that otherwise look identical.

11 — Connection

One profile carries both halves

A node needs a model and a sandbox, and one settings profile carries both. The plugin stores none of it — the platform holds the profile and ships it with every call as body.settings. Keys are matched leniently across case, spaces, dashes, underscores and the obvious synonyms, because profiles are often typed by hand.

The model
  • provider openai, anthropic, gemini, openrouter, groq, deepseek, mistral, together, fireworks, xai, ollama, openai-compatible
  • model as the provider spells it — must support tool calling
  • access_token required except for local
  • url base URL override; required for openai-compatible
  • temperature default 0.2
  • max_tokens cap on each model reply
  • request_timeout_s default 180
  • max_retries default 3
The sandbox
  • workspace_root absolute path — the only tree the agent may touch
  • max_file_bytes default 1 MiB
  • max_tool_output_bytes default 32 KiB
  • allow_shell default false
  • shell_allowlist program names
  • shell_denylist refused outright
  • shell_timeout_s default 300 — a ceiling the agent cannot raise
Provisioning
  • allow_install default false
  • install_allowlist empty means any
  • install_timeout_s default 300
  • allow_fetch default false
  • fetch_allowlist hosts; a host matches its own subdomains
  • fetch_max_bytes default 32 MiB
12 — Run it

Provision the plugin, then start the binary

The plugin must already be provisioned in a space — that is where PLUGIN_ID, INFRA_CRED and INFRA_URL come from. From FloMorphic, define the plugin under Extensions, then download the filled-in .env.inflow or run the generated one-liner.

git clone https://github.com/FloMorphic/legate
cd legate
cp .env.inflow.example .env.inflow   # PLUGIN_ID / INFRA_CRED / INFRA_URL
go run .

.env.inflow also carries what belongs to this machine rather than to a flow — the state directory and the panel — because a settings profile only exists at call time, folded into body.settings, and those have to be ready before the first call arrives.

Everything worth testing runs offline

internal/agent, internal/host, internal/skills, internal/stats and internal/panel import no sdkv1. That is what lets the whole agent — loop, tools, sandbox, library, history and panel — be tested without a platform to talk to.

The tests touch the real thing

The loop is exercised against a scripted model and a real workspace, so a passing test means a tool call really reached the filesystem. The host tools are tested through an actual in-process MCP client, not through the Go functions behind them.