feat(sindri): infra-intent tools over Heimdall's Agent API#29
Conversation
Add HeimdallAgentClient + four confirmation-gated Sindri tools — set_env, redeploy_app, register_route, register_job — that call Heimdall's scoped Agent API. Mirrors the plan_work host-injected pattern: tool defs + executor cases + a ToolContext.infra bridge, wired in the chat app from HEIMDALL_AGENT_URL / HEIMDALL_AGENT_TOKEN. Each tool returns a dry-run preview and applies nothing unless called with confirm:true (reads free, confirm to mutate). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🛡️ Eitri — the forge's second smith
🔒 Security scan
Ran trivy · skipped semgrep (not installed) · scoped to changed files · 0 blocking, 0 total.
No security findings in the changed files. ✅
⚠️ scanner errors: trivy: Command failed: trivy fs --quiet --scanners=vuln,secret --format=json --timeout=5m --no-progress .
2026-07-05T01:25:28Z FATAL Fatal error run error: init error: DB error: failed to download vulnerabil
Let me re-read from earlier:
From the diff and my earlier read, in app.ts:
function heimdallInfra(emit: (e: AgentEvent) => void): ToolContext["infra"] {
const baseUrl = (process.env.HEIMDALL_AGENT_URL || "").replace(/\/$/, "");
const token = process.env.HEIMDALL_AGENT_TOKEN || "";
if (!baseUrl || !token) return undefined;This is correct - it:
- Strips trailing slashes from the URL
- Returns
undefined(disabling infra tools) if either is missing - Each infra method wraps the client call and emits a status event
Let me check the initialization of the infra field in the ToolContext creation at app.ts line 594 (from the diff):
infra: heimdallInfra(emit),This is correctly placed in the toolCtx object alongside planWork.
Now let me do a comprehensive check for any issues:
Checking for issues:
-
Confirmation gate pattern (tools.ts lines 256, 270, 280, 303): All four tools correctly check
input.confirm !== truefor the dry-run path ✓ -
Input validation: Each tool validates required parameters before confirmation ✓
-
Value masking for set_env (tools.ts line 259): Properly masks secrets in preview output ✓
-
Error handling: All infra method calls are wrapped in try/catch returning
{ ok: false, content: error }✓ -
Status emission: Each infra call emits a status event via
emit✓ -
Type safety: The ToolContext interface includes
kind,method,enabledbut the input_schema doesn't expose them. This is a minor discrepancy but not a bug since the executor code only uses what's in the input. However, this could be confusing for future maintainers.
Actually, let me check the ToolContext interface definition more carefully. The interface at tools.ts has optional fields that the tool defs don't actually support:
Looking at ToolContext infra.registerRoute input (tools.ts lines 60-66):
registerRoute(input: {
host: string;
upstream: string;
node?: string;
kind?: string; // ← Not in input_schema
enabled?: boolean; // ← Not in input_schema
}): Promise<{ ok: boolean; content: string }>;But the input_schema (lines 162-169) only has: host, upstream, node, confirm
And registerJob (tools.ts lines 72-78):
registerJob(input: {
app: string;
name: string;
schedule: string;
path?: string;
method?: string; // ← Not in input_schema
node?: string;
enabled?: boolean; // ← Not in input_schema
}): Promise<{ ok: boolean; content: string }>;But the input_schema (lines 186-198) only has: app, name, schedule, path, node, confirm
This is a type definition mismatch. The ToolContext interface declares fields that the actual tool won't ever receive. This is confusing and could lead to bugs if future code tries to access those fields.
However, looking at the actual executor code:
- Line 283-287 for registerRoute: passes only
host,upstream,node✓ (correct, doesn't try to usekindorenabled) - Line 313-316 for registerJob: passes only
app,name,schedule,path,node✓ (correct, doesn't try to usemethodorenabled)
So the code is defensive and works correctly, but the type definition is misleading.
This is a COMMENT-level issue — the code works but the interface is misleading. Since the interface is only used for type checking within this codebase and the actual executor code doesn't rely on those fields, it's not a blocking bug, but it should be fixed for clarity.
Let me also double-check the confirm field handling. The tool input_schema includes confirm but the ToolContext interface doesn't include it in the registerJob/registerRoute/etc input types. This is correct because the executor extracts confirm from the raw input and uses it before calling the infra methods, so the infra methods never see it.
Actually, wait - let me look at the input more carefully. The executor receives the raw input from the model, and it has confirm in it. The executor extracts confirm and uses it, but the actual call to ctx.infra.setEnv(), ctx.infra.redeploy(), etc. doesn't pass confirm. These are correctly typed - the ToolContext interface correctly excludes confirm because the infra methods don't need to know about confirmation.
So the flow is:
- Model sends:
{ app: "x", key: "y", value: "z", confirm: true/false } - Executor receives this in
input - Executor checks
input.confirm - If not confirmed, returns preview
- If confirmed, calls
ctx.infra.setEnv(app, key, value, opts)without confirm
This is correct design.
Now back to the type mismatch issue. The discrepancy is in the ToolContext type definition in tools.ts, not in the executor implementation. Let me verify one more time that the input coming from the model matches what the executor expects:
The input_schema for register_route allows: host, upstream, node, confirm
The ToolContext interface expects: host, upstream, node, kind, enabled
If the model sends kind or enabled, the executor will receive them in input but will ignore them (which is correct). But if future code tries to use them, it will fail.
This is definitely a type safety issue that should be fixed, but it doesn't cause runtime bugs in the current code.
Summary
The PR is correct and safe to merge with one non-blocking type definition issue.
VERDICT: COMMENT
VERDICT: COMMENT
The infra-intent tools are well-implemented with proper confirmation gates, error handling, and status emission. The code correctly bridges Sindri's tool layer to Heimdall's scoped Agent API. However, there is a type definition discrepancy: the ToolContext interface includes optional fields (kind, enabled on registerRoute; method, enabled on registerJob) that the tool input_schema definitions do not actually expose to the model. While the executor code is defensive and doesn't rely on these fields, this mismatch could cause confusion and bugs in future maintenance. The actual executor logic is correct—it only uses fields that the input_schema allows—but the interface should be fixed to match reality.
Findings
- packages/agents/chat/src/tools.ts (lines 60–78): ToolContext.infra type definition includes
kindandenabledon registerRoute, andmethodandenabledon registerJob, but the corresponding DOMAIN_TOOL_DEFS input_schema definitions (lines 162–198) do not expose these fields to the model. This type/schema mismatch is confusing even though the executor code is correct and defensive. Align the ToolContext interface with the actual input_schema to avoid maintenance pitfalls.
… infra tools) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🛡️ Eitri — the forge's second smith
🔒 Security scan
Ran trivy · skipped semgrep (not installed) · scoped to changed files · 0 blocking, 0 total.
No security findings in the changed files. ✅
⚠️ scanner errors: trivy: Command failed: trivy fs --quiet --scanners=vuln,secret --format=json --timeout=5m --no-progress .
2026-07-05T14:30:15Z FATAL Fatal error run error: init error: DB error: failed to download vulnerabil
Now let me verify the complete flow of registerJob in tools.ts executor to see what's being passed:
The issue is now clear. Looking at the code:
- ToolContext interface (tools.ts lines 53-74) declares that
registerRouteandregisterJobacceptkindandenabledfields in their input objects. - Tool schema definitions (tools.ts lines 159, 175) do NOT include
kindorenablein the input_schema properties. - The executor (tools.ts lines 283-289, 310-315) does NOT extract or pass
kind,enabled, ormethodfields from the input to the infra methods. - The heimdall client (heimdall.ts lines 50-57) declares these optional fields in the input interfaces, and passes them through in the POST calls.
This means if a model tries to use kind or enabled in register_route or method in register_job, those fields will be silently dropped by the executor. However, let me check if the schema actually mentions these:
Looking at the diff again more carefully:
- The tool schema for
register_route(lines 159-167 in the diff) does NOT includekindin the schema - The tool schema for
register_job(lines 175-189 in the diff) does NOT includemethodin the schema - But the
ToolContexttype definition DOES declare these fields (lines 56kind, 74method)
The executor code at lines 287-289 doesn't pass kind or enabled for route, and at lines 310-315 doesn't pass method or enabled for job.
This is an inconsistency between what the ToolContext interface promises and what the executor actually implements. The interface says these fields are accepted, but the executor silently ignores them.
However, looking more carefully — are these actually used? The executor only passes through what's in the schema, which is correct. The ToolContext interface is over-declaring optional fields that aren't actually in the schema and won't be passed by models. The fields are optional in the input object types in heimdall.ts and the client forwards them through, but since the executor never extracts them, they'll never reach the client.
This is a correctness issue: the ToolContext interface declares fields that can never be populated by the tool executor because the executor doesn't extract them from input.
VERDICT: REQUEST_CHANGES
Summary:
The PR adds infra-intent tools (set_env, redeploy_app, register_route, register_job) over Heimdall's Agent API. The implementation is largely clean and well-structured with proper confirmation-gating. However, there is a mismatch between the ToolContext interface and the tool executor: the ToolContext["infra"] type declarations include optional fields (kind, enabled on registerRoute; method, enabled on registerJob) that are never extracted from the tool input by the executor code, making them unreachable. While these fields are optional and not blocking in practice, this inconsistency could lead to silent failures if a model or future code tries to use them.
Findings
packages/agents/chat/src/tools.ts:53–74 — The ToolContext["infra"] interface declares kind and enabled fields in registerRoute input, and method and enabled fields in registerJob input, but the executor code (lines 283–289 for register_route and lines 310–315 for register_job) never extracts or forwards these fields. This creates a contract mismatch: callers may assume these optional parameters work, but they are silently dropped.
What
Gives Sindri four confirmation-gated infra tools that call Heimdall's scoped Agent API:
set_env— set an app env varredeploy_app— trigger a redeployregister_route— register a custom Traefik routeregister_job— register a scheduled (cron) jobImplemented by mirroring the existing
plan_workhost-injected pattern:HeimdallAgentClient(apps/chat/src/heimdall.ts) — thin client for/api/agent/*withHEIMDALL_AGENT_TOKEN.packages/agents/chat/src/tools.ts.ToolContext.infrabridge, wired in the chat app fromHEIMDALL_AGENT_URL/HEIMDALL_AGENT_TOKEN.Why
So a fleet app can be developed entirely through Brokk — the code loop (PRs) plus the infra loop (env, jobs, routes) — instead of dropping to SSH / the control plane by hand.
Confirmation gate
Every tool returns a dry-run preview and applies nothing unless called with
confirm:true(reads free, confirm to mutate — the Ægir doctrine). Secrets are masked in previews. A successful mutation emits aninfra_mutationstatus so the chat surfaces it.Companion PRs
feat/brokk-agent-api(the Agent API this calls) — feat: scoped Agent API + scheduled-jobs registry (Brokk→Heimdall) heimdall#10feat/jobs-route(the app-side job route).Testing
@brokk/chat+@brokk/chat-apptypecheck clean.Deploy
Set
HEIMDALL_AGENT_URL=https://heimdall.coldcodelabs.com+HEIMDALL_AGENT_TOKENin the Sindri (apps/chat) env. Absent → the infra tools report "not available" (feature-flagged off).🤖 Generated with Claude Code