Repository navigation
Architecture
How a kind: agent step executes in the NoETL local runtime, which component owns what, and why it is shaped this way.
flowchart LR
T[systemd timer] -->|noetl run --runtime local| R
subgraph CLI ["noetl CLI (noetl/cli)"]
R[PlaybookRunner<br/>src/playbook_runner.rs]
B[tools_bridge<br/>executor/src/tools_bridge.rs]
R --> B
end
subgraph TOOLS ["noetl-tools (noetl/tools)"]
S[AgentSession<br/>src/tools/agent.rs]
end
B --> S
S -->|framework: claude<br/>HTTPS + x-api-key| API[(Anthropic<br/>Messages API)]
S -->|framework: claude-code<br/>subprocess| CC[claude -p]
CC --> SUB[(Claude account)]
R -->|tool call| P[tool sub-playbook<br/>PlaybookRunner]
| Component | Owns | Source |
|---|---|---|
PlaybookRunner |
walking the workflow, rendering templates, driving the agent loop, running tool sub-playbooks |
src/playbook_runner.rs (Tool::Agent arm, run_agent_tool_call) |
tools_bridge |
mapping the YAML step to a noetl-tools config; local credential resolution |
executor/src/tools_bridge.rs (start_agent_session, local_credential_env_var) |
Tool::Agent |
the YAML shape of the step | executor/src/playbook.rs |
AgentSession |
talking to the model: request building, response parsing, turn and token accounting, the Claude Code subprocess | src/tools/agent.rs |
AgentTool |
registry entry agent for tool-free agents (used by any host that cannot run tools) |
same file |
Links point at main: noetl-tools 4.1.0 (noetl/tools#112) and noetl CLI 5.1.0 (noetl/cli#95).
sequenceDiagram
participant R as PlaybookRunner
participant S as AgentSession
participant A as Messages API
participant P as tool sub-playbook
R->>S: start_agent_session(rendered step)
Note over S: parse config, resolve auth,<br/>messages = [user: prompt]
loop until Final or max_turns
R->>S: next_turn()
S->>A: POST /v1/messages (model, system, messages, tools)
A-->>S: content blocks + stop_reason + usage
alt tool_use blocks present
S-->>R: ToolCalls([...])
loop each call
R->>P: run tool's playbook, input as workload.*
P-->>R: RunOutcome (final_result or error)
end
R->>S: submit_tool_results([...])
else text only
S-->>R: Final({text, turns, usage, ...})
end
end
R->>R: store step result
-
Render. The runner renders the step's templates (
prompt,system,model,tools,auth, …) against the playbook context, the same way every other tool is rendered. -
Start.
start_agent_sessionresolvesauth.credentiallocally (see Credentials), builds the noetl-tools config, and callsAgentSession::start, which validates the config and seedsmessageswith the prompt. -
Turn. Each
next_turnis onePOST /v1/messages. The assistant message is appended to the history;tool_useblocks becomeAgentToolCalls. A turn with no tool calls is final. -
Tools. For each call the runner looks up the tool by name, resolves its
playbook:relative to the agent playbook, and runs it with a freshPlaybookRunner. The model's input object becomesworkload.<key>variables. The sub-playbook'sfinal_result(or its step results, or its error) becomes thetool_resultcontent. A failing tool is reported to the model withis_error: true; it does not fail the step, so the model can recover. -
Bounds.
max_turnscounts model calls. The(max_turns + 1)-thnext_turnfails the step withagent did not finish within max_turns=N.
The agent's tools are playbooks, and the CLI's PlaybookRunner::run is synchronous and enters tokio itself (block_in_place + block_on). Running a sub-playbook from inside an async agent loop would nest block_on calls. So the session exposes one model turn per call, and the runner runs tools between turns, outside any block_on, exactly as a kind: playbook step does.
The same shape matters beyond the CLI: the session's whole state is its JSON message history, so a distributed runner could persist it between turns instead of holding a worker slot while a tool runs. That is what NoETL's execution model asks of any long-running block.
sequenceDiagram
participant R as PlaybookRunner
participant S as AgentSession
participant C as claude -p
R->>S: start_agent_session (no auth, no tools allowed)
R->>S: next_turn()
S->>C: spawn with args, env minus ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
S->>C: prompt on stdin
Note over C: Claude Code runs its own loop<br/>with allowed_tools / mcp_config only
C-->>S: one JSON result on stdout
S-->>R: Final({text, turns, usage, framework, permission_denials})
-
Arguments:
-p --output-format json --no-session-persistence --model <model>, plus--append-system-prompt <system>,--strict-mcp-config --mcp-config <files…>,--allowed-tools <tools…>when set.--append-system-promptkeeps Claude Code's own system prompt, which carries its tool instructions. -
Prompt on stdin, never as an argument:
--allowed-toolsand--mcp-configtake variable-length lists that would swallow a trailing positional prompt. -
Billing guard: the two API-key variables are removed from the child's environment, so a key exported for
framework: claudeplaybooks cannot silently switch this run to API billing. -
Result: the CLI's JSON
resultbecomestext;is_error: truefails the step with that text; non-JSON output fails with the exit status and the tail of stderr. -
Bound: the step
timeout(default 300 s); the child is killed on timeout. -
No NoETL tools: Claude Code's tool calls happen inside the subprocess, invisible to NoETL. That is why
tools:is refused here — to have NoETL see and gate every tool call, useframework: claude.
flowchart LR
Y["auth:<br/>credential: anthropic"] --> V{"$NOETL_CREDENTIAL_ANTHROPIC<br/>non-blank?"}
V -->|yes| X[value]
V -->|no| F{"~/.config/noetl/credentials/anthropic<br/>exists, mode 600?"}
F -->|yes| X
F -->|missing or blank| E[step fails before any call]
F -->|readable by others| E
X -->|agent| H[x-api-key header]
X -->|"http google_oauth"| R[refresh → cached access token] --> B[Bearer header]
- The playbook names an alias, never a key. This follows NoETL's no-default-connection rule: no
auth:, no call. - Local mode has no server keychain, so the alias
Xresolves from the environment variableNOETL_CREDENTIAL_+Xupper-cased with non-alphanumerics as_, else from the file~/.config/noetl/credentials/X, which must be mode600. The file store needs noetl 5.2.0 (noetl/cli#96). - The same resolver serves
agentsteps andhttpsteps (type: bearer | api_key | google_oauth); source:executor/src/local_credentials.rs. -
google_oauthcredentials hold a refresh token. The bridge exchanges it atoauth2.googleapis.com/tokenand caches the access token per alias until a minute before it expires, so an agent making many Gmail calls in one run refreshes once. - Keys are never in a unit file, a playbook, the process arguments, or step results.
- The step config is rendered once, before the session starts.
-
Model output is data. Tool inputs from the model are passed to the sub-playbook verbatim — not rendered by the agent runner — and
{{is rewritten to{ {. The rewrite was added becauserender_templateup to noetl 5.3.0 re-expanded values it had already substituted, so{{ workload.secret }}inside an email quoted back by the model would have been expanded by the tool playbook's own templates. noetl 5.3.1 renders in a single pass (noetl/cli#98); the rewrite stays as defence in depth. - In playbooks that put fetched data into the prompt (
{{ fetch.result }}), the CLI substitutes it once (noetl 5.3.1+). Since 5.3.2,shell/httpsteps are no longer re-rendered by noetl-tools (noetl/ai-meta#477), so that data reaches the tool unchanged.
Stored as the step's result (a JSON string), so later steps can use {{ <step>.result }}:
| Field | claude |
claude-code |
|---|---|---|
text |
final text blocks joined | CLI result
|
turns |
model calls made | CLI num_turns
|
usage.input_tokens / output_tokens
|
summed over turns | as the CLI reports |
stop_reason |
API stop_reason
|
CLI terminal_reason / subtype
|
model |
✓ | ✓ |
framework |
— | "claude-code" |
permission_denials |
— | tools Claude Code tried and was refused |
-
Distributed runtime. The NoETL server rejects
agentat playbook registration and workers don't dispatch it. Running agents on the worker pool needs a callback-shaped design so a model turn doesn't hold a slot. -
Streaming, prompt caching, extended thinking are not wired into the
claudeloop.
- Configuration Reference · Security · Tutorial
- NoETL execution model: ephemeral blueprints
agent-playbooks
Code