Halo a language for AI pipelines
Declare agents, tools, and the policies that bound them; chain them with pipeline operators. The runtime checks every tool call an agent makes against its policies before it runs. halo plan shows what a run could do before it starts; halo replay reruns exactly what it did.
Outputs are the real CLI, run on these files when this page was built. halo test mocks every model and tool, so it runs offline.
Install
$ curl -fsSL https://halolang.com/install.sh | shPS> irm https://halolang.com/install.ps1 | iex$ bun install && bun link # in a checkout; `halo` runs the sourceOne standalone binary with Bun built in: nothing else to install. The script picks the build for your OS and CPU, checks it against the release's SHA256SUMS, and puts it in ~/.halo/bin. Read the script before piping it to a shell. Update with halo upgrade.
$ halo init my-flow && cd my-flow
$ halo test main.halo --arg topic=tides # offline: every model and tool mocked
$ halo run main.halo --arg topic=tides # with ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY set
Builds: macOS (Apple Silicon, Intel), Linux x64 and arm64 (glibc and musl), Windows x64. Editor support (VS Code with a language server, Zed, Sublime) is in editors/.
Syntax at a glance
Declarations
policy Safe : Block(PII) & MaxSpend(1.00)
tool Web : Browser(maxChars: 8000)
schema Out { title: String, score: Number }
@retry(2, 5s)
agent Writer [anthropic/claude-sonnet-4-5, temp: 0.3]
equips Web
requires Safe
system "You write short, sourced summaries."
pipeline Brief(url: String) -> Out:
url |> Writer.summarize(tool: Web)
Operators
|> | call an agent, tool, or pipeline |
|? (cond) | gate: stop unless it holds |
|? { (c) => A(), else => B() } | branch |
|* { A, B } | run in parallel |
|* each(x in list) => A() | map over a list |
|<> loop(Agent, "cond", max: 3) | retry until it passes |
|! yield(msg: "...") | ask a person; the run pauses |
~> listen(Event) | wait for an outside event |
as name | bind a result: context.data.name |
Values in a stage
"Hello {{name}}" // template: params, bindings
context.data.review.approved // anything earlier stages made
context.meta.cost // spend so far (USD)
Env.GITHUB_TOKEN // env, only in tool declarations
[A, B] 3 2s true null // lists, numbers, durations, ...
Agents that choose tools
agent Fixer [anthropic/claude-sonnet-4-5, max_steps: 20]
equips Code, Tests
requires FixerTools
pipeline Fix(dir: String) -> Result:
dir |> Fixer.fix("Make the tests pass.", tools: [Code, Tests])
// or: [..., tools: auto] on the agent
Full grammar and every built-in: language reference →
Agents with tools
With tools: [...] the agent calls tools until it has an answer, using each provider's native tool calling (Anthropic, OpenAI-compatible, Gemini, Ollama). Each call is checked against the agent's policies before it runs; results pass through its Redact/Block rules before the model reads them; errors go back to the model instead of failing the run.
Below is a real run of examples/74-software-team.halo's fixer on a project with two bugs, as halo run recorded it.
Policy rules
Policies attach to agents with requires. The runtime enforces these deterministically; there's no model judging another model.
| Rule | Checks | Example |
|---|---|---|
| Tool calls an agent chooses | ||
AllowTools | Only these methods are offered at all | AllowTools("Code.*", "Tests.run") |
DenyTools | Refuse these methods | DenyTools("Files.delete") |
AllowEffects | Only methods with these effects | AllowEffects(read, write) |
DenyArgs | Refuse calls whose argument matches a regex | DenyArgs("Sh.run", "command", "^(?!bun test)") |
ApproveTools · ApproveEffects | Ask a person first; the run pauses until answered | ApproveEffects(external) |
| Content in and out | ||
Block | Stop a prompt, answer, or tool result that contains it | Block(PromptInjection, APIKey) |
Redact | Remove it before the model or the logs see it | Redact(Email, SSN, CreditCard) |
Enforce · Require | The answer is JSON / XML / cites sources | Enforce(JSON) |
MatchRegex · Validate | The answer matches, or a field compares | Validate("amount", "<=", "context.data.limit") |
MaxWords · MaxChars · MaxTokens | Length limits | MaxWords(120) |
| Spend | ||
MaxSpend | Stop when the run has spent this much; each call's reply is capped to fit | MaxSpend(3.00) |
Add @correct(n) to send a rejected answer back to be fixed. All rules and categories →
Plan & replay
halo plan
Static: every model call with its worst-case cost, every tool it could call and its effect (read / write / external), what needs approval, and the spend cap. Nothing runs.
halo replay
Every run records what it got back: model answers, tool results, people's answers. Replay feeds them back with no calls. --live-models or --live-tools redo one side. Unrecorded tools are refused, not run.
CLI
Tool providers
Declare a tool with tool Name : Provider(args). Each method has an effect, which policies and halo plan use: read write external.
| Provider | Methods | From |
|---|
Packages
Any API can ship as a Halo tool. A package is a halo-tool.json declaring providers of three kinds: http (declarative REST; no package code runs, requests stay under the declared URL), mcp (an MCP server), or module (TypeScript; needs --trust). Packages are checksummed, pinned in halo.lock, and verified on every load; trust is per machine, never read from the lock.
$ halo search payments
$ halo add stripe
$ halo add world-time --trust
$ halo tools
$ halo install --trust # on a new machine
# publish your own
$ halo pack ./my-api
$ halo publish my-api-1.0.0.halotool --registry ./index.json
halo serve
Runs the same files as a service: HTTP API, webhooks, cron schedules, watches, approval links and Slack buttons, MCP at /mcp, and a run viewer at /. It listens on localhost unless you set a token.
{
"files": ["pipelines/"],
"schedules": [{ "pipeline": "DailyDigest", "cron": "30 8 * * 1-5" }],
"webhooks": [{ "path": "github", "pipeline": "Triage", "secret": "Env.GH_HOOK_SECRET" }],
"watches": [{ "pipeline": "OnStatusChange", "url": "https://status.example.com/api.json", "every": "2m" }],
"notify": { "slack": "Env.SLACK_WEBHOOK_URL" },
"slack": { "signingSecret": "Env.SLACK_SIGNING_SECRET" }
}
$ halo serve --port 8080
$ curl -X POST localhost:8080/api/pipelines/Triage/runs -H 'content-type: application/json' -d '{"args":{"issue":42}}'
$ claude mcp add halo -- halo mcp ./pipelines # pipelines as MCP tools
Playground
Halo's real parser, validator, and planner, compiled for the browser. Nothing leaves the page; no model is called.