Skip to content

Halo DSL ​

A language for orchestrating AI workflows with declarative infrastructure, safety policies, and pipe-based composition.

Language Design ​

Halo is a declarative, pipeline-oriented language for expressing AI workflows as readable, auditable data flows. Instead of imperative boilerplate (try/catch, if/else, retry loops, memory arrays), you write declarations (agents, tools, policies, schemas) and pipelines joined by pipe operators.

Types and state ​

Halo is dynamically typed and statically validated (halo validate). Parameter and return types are written as String, Number, Integer, Boolean, Array / List (optionally Array<T>), Object / JSON, Any, or a declared schema. Only a pipeline's return type is checked at runtime, and only when it names a schema.

The Global State (context) ​

NamespacePurpose
context.currentThe value flowing through the pipe right now
context.inputThe pipeline's arguments
context.dataResults: the pipeline's arguments, every call at context.data.<Callee>.<method> and context.data.<method>, and every as name binding
context.metaRun data: cost, tokens, every call with latency and attempts, policy violations, shadow results, halted/paused state
context.traceSHA-256 hash chain of every stage touched (tamper-evident)

Declarations ​

policy: the rules ​

Checked on an agent's prompt (input) and answer (output) for every agent that requires the policy. All rules are deterministic; rules that would need a model to judge them (e.g. Tone) are recognized but not enforced, and halo validate warns about them.

halo
policy Privacy : Redact(PII) & Block(PromptInjection)
policy Budget  : MaxSpend(5.00) & MaxTokens(800)
policy Shape   : Require(JSON) & Validate("payout", "<=", "context.data.coverage.max_payout")

tool: the hands ​

Outbound connections. An agent may only use tools it equips (zero trust).

halo
tool CoreDB : PostgreSQL(Env.DB_URI)
tool GitHub : REST("api.github.com")
tool Shell  : Bash()
tool Chat   : Slack(Env.SLACK_BOT_TOKEN)

store: memory ​

store declares a store the same way tool does. VectorStore keeps entries in a local SQLite file; an agent that equips it gets the entries closest to each call's input. A File store holds an agent's conversation history.

halo
store KnowledgeBase : VectorStore(namespace: "engineering")
store History       : File(".halo/history.json", keep: 10)

schema: output shapes ​

halo
schema Profile {
  name: String
  age: Number
  tags: Array<String>
}

A pipeline declared -> Profile must return matching JSON. A model's text answer is parsed first, and a mismatch fails the run with a SchemaValidationError (e.g. missing field "age").

agent: the brains ​

A model binding with optional modifiers.

halo
agent DataAnalyst [gemini-2.5-pro, temp: 0.1]
  system """
    You are a careful data analyst. Show your reasoning briefly.
  """
  equips CoreDB, KnowledgeBase
  requires Budget
ModifierPurpose
system "..."The system prompt sent with every call ("..." or """...""")
equipsGrants the agent permission to use specific tools and stores
requiresBinds policies. halo validate fails if one is missing

pipeline: the flow ​

pipeline Name(arg: Type, other: Type) -> ReturnType:
  arg
    |> Agent.method(...) as result
    |> ...

A pipeline starts with an expression (usually an argument) and continues with stages.

Operators ​

OperatorNamePurpose
|> Call(...)PushRun a call on the current value. The result becomes the current value
|? (cond)GateContinue only if the condition is true; otherwise the run halts (status halted)
|? { ... }BranchRun the first arm whose condition holds (if/else)
|* { A(), B() }Fan-outRun calls in parallel. The result is the list of outputs
|* each(x in list) => Call()EachRun one call per list item, in parallel
|<> loop(Agent, "cond")LoopWhile the condition holds, go back to the agent's stage
|! yield(...)InterruptAsk a human
~> listen(Source.Event)ListenWait for an external event

A call is Agent.method(...), Tool.method(...), System.Method(...), or OtherPipeline(...).

Directives ​

DirectivePurposeExample
@retry(n, duration)Retry each model/tool call up to n times on transient failures (network, 429, 5xx, timeouts). Policy violations are never retried@retry(3, 2s)
@timeout(duration)On a pipeline: abort the whole run. On an agent: abort each call@timeout(5m)
@checkpointSnapshot state after every stage (and at a closed gate); continue with halo run --checkpoint <file>@checkpoint
@shadow(Primary)On an agent: run it alongside every call to Primary and record its output, which is never used. @shadow(openai/gpt-4o) runs the same prompt on that model instead@shadow(Baseline)
@correct(n)On an agent or pipeline: when an output policy rejects an answer, tell the agent what was wrong and ask again, up to n times@correct(2)

Bindings, Conditions & Strings ​

|> Analyst.analyze_trends(metrics: context.data.stripe) as trends   // context.data.trends
|* { A.fetch() as a, B.fetch() as b } as both                       // per-item and whole-block bindings
|? (context.data.trends.notable_changes > 0 && !(region == null))   // && || ! == != > < >= <= null
|> Writer.prompt("""
     Multi-line prompt for {{company}}.
     Previous result: {{trends.summary}}
   """)
|> Summarize(context.data.trends)                                   // call another pipeline

Keywords (tool, context, pipeline, ...) can be used as named-argument keys, e.g. Agent.fetch(tool: Stripe). Env.NAME can only be read in tool declarations, so secrets never reach prompts.

Branching & Loops ​

|? {
     (context.data.screen.score < 0.3) => System.Publish(context.data.post),
     (context.data.screen.score > 0.9) => System.Notify("removed"),
     else => DeepReview(context.data.post),
   } as outcome

|* each(issue in context.data.triage.issues, concurrency: 2) => GitHub.add_labels(
     path: "/repos/{{repo}}/issues/{{issue.number}}/labels",
     labels: issue.labels
   ) as labeled

|> SWE.write_code()
|> Shell.run(command: "bun test")
|<> loop(SWE, "context.data.Shell.run.exitCode != 0", max: 3) as retries

Branch (|? { ... }) checks arms in order and runs the first one whose condition holds; else (last) matches anything. Each arm is one call. For several steps, call another pipeline; a gate inside that pipeline ends just that pipeline early, and the branch gets its last value. The arm's result becomes the flowing value and the as binding. If nothing matches and there is no else, the value passes through unchanged.

Each (|* each(item in list) => Call(...)) runs the call once per item, up to concurrency (default 4) at a time, and keeps the input order. Inside the call, the item is available as item, {{item.field}}, and the flowing value, and its position as index. The loop variable doesn't exist after the loop. The result list is stored at the as binding and at context.data.<Callee>.<method>. If any item fails, the stage fails.

Loop (|<> loop(Agent, "cond", max: n)) checks its condition (written as a string) when reached. While it holds, the run jumps back to the agent's most recent stage, so the agent sees the current value (e.g. the failing test output) and tries again. After max tries (default 3) the run fails with a LoopLimitError. When the condition is false the run continues, and as binds the number of retries.

Human-in-the-Loop ​

|! yield(...) sends a request to the runtime's interrupt handler. The request has message (from msg:/message: or a string arg), channel (from channel:/queue: or a tool name), attachments (list args), and timeoutMs (from a duration). The response is stored at context.data.yield and any binding. ~> listen(Source.Event) waits for the listen handler and stores the event at context.data.Source.Event. Neither changes the flowing value.

With no handler, a top-level run pauses: it returns status paused with a runId, and the run is saved to the runtime's run store. runtime.signal(runId, "yield", response) (or the listen target, e.g. "Slack.Reaction") continues it. The CLI asks in a terminal; elsewhere it pauses, saves the run to .halo/runs, and prints the halo resume <file> <runId> --payload ... command. The CLI turns a typed answer into an event ({ action, approved, ... }), so gates and branches can check context.data.yield.approved or context.data.yield.action == "label".

Full Example ​

halo
policy Budget    : MaxSpend(1.00)
policy Shape     : Require(JSON)

tool   Warehouse : SQL(Env.WAREHOUSE_URL)
tool   Deploys   : POST("ci.example.com/deploy", apiKey: Env.CI_TOKEN)
store  Incidents : VectorStore(namespace: "incidents")

schema Diagnosis {
  root_cause: String
  confidence: Number
}

agent Analyst [anthropic/claude-sonnet-4-5, temp: 0.0]
  system "You diagnose sales drops from data. Answer in JSON."
  equips Warehouse, Incidents
  requires Budget, Shape

@timeout(5m)
@retry(2, 5s)
pipeline ResolveSalesDrop(event: String) -> Diagnosis:
  event
    |> Analyst.query(tool: Warehouse, sql: "SELECT region, revenue FROM daily_sales LIMIT 50") as sales
    |> Analyst.diagnose(context.data.sales) as diagnosis
    |? {
         (context.data.diagnosis.confidence < 0.7) => System.Notify("Low-confidence diagnosis", event: event),
       }
    |! yield(msg: "Ship the fix for this diagnosis?", [context.data.diagnosis]) as review
    |? (context.data.review.approved == true)
    |> Deploys.trigger(service: "pricing")
    |> Incidents.log(content: context.data.diagnosis)
    |> System.Pick(context.data.diagnosis, fields: ["root_cause", "confidence"])

Editor Support ​

EditorStatusPath
VS Code✅ Highlighting + language server (errors, hover, completion)editors/vscode/
Zed✅ Tree-sitter highlightingeditors/zed/
Sublime Text✅ Highlightingeditors/sublime/
JetBrains⚠️ Planned—

Dictionary ​

See docs/dictionary.md for the full formal lexicon.

Building ​

sh
bun install
bun run generate     # rebuild the parser from grammars/Halo.g4 (needs Java)
bun test
bunx tsc --noEmit

curl -fsSL https://halolang.com/install.sh | sh