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.
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.
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.
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.
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 serverThe 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.
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.
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.
Skills live outside workspace_root. Inside it, fs_write would let one instruction rewrite the standing orders of every run that came after it.
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.
What holds the agent in
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.
The shell is opt-in
With it off the agent still reads, writes, searches and edits files — it just cannot run programs.
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.
Bounded output
Each tool result is capped before it reaches the model, so one broad search cannot fill the context window.
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.
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.
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.
That separation is the whole of how curl … | sh is avoided: bytes arrive as data, and something else has to decide to run them.
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
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.
Starting work stays with the flow — which is where the platform's own record of what was asked for lives.
Two actions, and an output a flow can branch on
| Method | Title | Purpose |
|---|---|---|
legate.agent.run | Run Instruction | The agent: prompt in, work done on this machine, outcome out. |
legate.tool.call | Call Tool | One 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 */ ]
}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 purposereply 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.
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.
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.
| Context | Node scope | Instruction field |
|---|---|---|
{"tasks": [{"goal": "…"}, {"goal": "…"}]} | $.tasks[*] | {{$this.goal}} |
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}}.
Route outbound ports by tool used filters outbound edges by tag, and a loop's untagged return edge would be filtered away.
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).
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
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 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 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.
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.
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.
provideropenai, anthropic, gemini, openrouter, groq, deepseek, mistral, together, fireworks, xai, ollama, openai-compatiblemodelas the provider spells it — must support tool callingaccess_tokenrequired except for localurlbase URL override; required for openai-compatibletemperaturedefault 0.2max_tokenscap on each model replyrequest_timeout_sdefault 180max_retriesdefault 3
workspace_rootabsolute path — the only tree the agent may touchmax_file_bytesdefault 1 MiBmax_tool_output_bytesdefault 32 KiBallow_shelldefault falseshell_allowlistprogram namesshell_denylistrefused outrightshell_timeout_sdefault 300 — a ceiling the agent cannot raise
allow_installdefault falseinstall_allowlistempty means anyinstall_timeout_sdefault 300allow_fetchdefault falsefetch_allowlisthosts; a host matches its own subdomainsfetch_max_bytesdefault 32 MiB
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.
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 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.