23. Working in Hanki as an agent: the loop
The tooling below is one loop and no set of disconnected flags. Sketch the program with typed holes and let hanki check dictate each gap. Learn the surfaces you call from hanki doc --format=json and hanki query in place of reading source into context. Close diagnostics with explain, --fix, re-run, in place of re-deriving edits. Test with hanki test --format=json, property tests for universally-quantified claims and --deterministic for actor code. Run just-written code under the allowance hanki effects reports, through --allow and --deny. And before landing a change to a module others consume, let hanki api-diff classify the contract delta. HANKI-CARD.md §22 gives the same loop in compact form, the version every scaffolded project's agent reads. The bullets below are the per-tool depth:
- A panic in the toolchain reports as an internal compiler error. Any panic that escapes a command is caught, and printed as a report that says whose defect it is, with the version and the subcommand, and asks for the source that triggered it:
error: internal compiler error - this is a bug in hanki, not in your program
<the panic message>
at <file:line:col>
hanki 0.0.1 while running: check
Please report it with the source and the command that triggered it.
Re-run with RUST_BACKTRACE=1 for a backtrace to include.
The exit code is 70 (EX_SOFTWARE), outside the 250-254 block §22 reserves for a program's language faults: the toolchain failing and the program failing are different events. RUST_BACKTRACE=1 still produces a backtrace, printed under the report in place of a bare panic. An actor handler that panics is not this: the scheduler catches it and reports it through the death protocol (§15) as a HostPanic death, which is a fact about the running program.
- A human-facing diagnostic shows the source it is about.
hanki check/build/testprint the canonicalLABEL:line:col: marker[CODE]: MSGline, then the offending source line and a caret run under the span:
main.hk:2:3: error[H0201]: type mismatch: expected int, found string
2 | "not an int"
| ^^^^^^^^^^^^
The caret counts Unicode scalars, matching the column the first line reports, and clips to the line's own length. A synthetic span, a @derive or where-expansion node with no source position, prints the header line alone. --format=json is unchanged: its rendered field gives the one line, which is what an agent parses.
- Diagnostics have stable codes. A coded diagnostic prints as
error[H####]:, for instanceerror[H0601]: call requires effect …. The code is an opaque, append-only handle,Hplus four digits, grouped by compiler phase:H00xxlexer,H01xxparser,H02xxthroughH05xxchecker,H06xxeffects and throws. The prose message gives the meaning, and the code is a stable lookup key for tools and agents. One registry lists every code, and a test pins them: a shipped code's number and title never change and are never reused. Not every diagnostic is coded yet: the lexer, parser, and effect and throws families are, and the rest of the checker is being filled in. --format=jsongives structured diagnostics.hanki checkandhanki builddefault to the human prose above. Pass--format=jsonand they emit one versioned envelope to stdout instead:{"schema": "hanki-diag-v1", "diagnostics": [{code, severity, span:{path,start,end}, message, rendered, fixes}]}.codeis theH####string, ornullfor an uncoded diagnostic;spanis byte offsets intopath;messageis the bare text andrenderedthe fullfile:line:col: error[H####]: …line, which lets an agent read prose without re-deriving it. A clean check emits an emptydiagnosticsarray, and the exit code is unchanged, non-zero on any error. JSON is the only structured encoding: the binarycborform was dropped as having no consumer, and a Hanki-native consumer would reuse the same envelope with a swapped encoder. In JSON mode stdout is one envelope and no more, and build progress,wrote?andlinked, moves to stderr.fixesare machine-applicable edits. Each diagnostic hasfixes: [{span:{path,start,end}, replacement, applicability}], the source edit that resolves it.spanis the byte range to replace,start == endbeing a pure insertion, in the same file as the diagnostic, andapplicabilityismachine-applicable, always correct,maybe-incorrect, orhas-placeholders. Today the effect-propagation families emit them:H0601, a call needing an effect the action does not declare, andH0604, athrowwith no matchingthrowsrow, attach the precise[…]-row patch, widening an existing row or inserting a fresh one after the return type.hanki check --fixapplies fixes to the working tree. It writes everymachine-applicablefix straight to the source, then re-checks and repeats until the cascade settles: adding[net]to an action surfaces the same need one caller up, and a chain of N actions therefore converges in N rounds, with no agent re-derivation. Edits are minimal, canonically-spaced text-span replacements, and once the cascade settles--fixruns the formatter over each file it changed to normalize it, apply and then format. The formatter preserves comments, and yours survive the pass. Diagnostics needing human judgement are left untouched and reported as usual. This collapses the agent loop from parse, re-derive, patch to run, re-run.hanki explain <code>is the recipe for a code.hanki explain H0601prints the code's title, a canonical explanation, and the fix recipe, andhanki explainwith no argument lists every code. The agent loop therefore reads end to end: a failedhanki checkprintserror[H####],hanki explain H####says what it means and how to fix it, andhanki check --fixapplies the machine-applicable ones. That one registry is the single source of truth for code, title and explanation, and the three cannot drift.hanki queryanswers "what is this?" at a source position, which lets an agent query semantics in place of reading files into context.hanki query FILE --at LINE:COL, with a 1-based line and byte column, or--byte N, prints the hover answer for the identifier there: the declaration signature, effect row included, or the binder'sname: type, plusdefined at file:line:col. It is the LSP hover and definition surface as a one-shot command, with no session needed.--format=jsonemits onehanki-query-v1envelope to stdout:{"schema": "hanki-query-v1", path, byte, hover: {span:{start,end,line,col}, contents} | null, definition: {start,end,line,col} | null}, wherecontentsis the same fenced signature andhover: nullmeans nothing identifier-like sits at the position. The exit is 0 in JSON mode, and text mode exits 1 on a miss.hanki doc --format=jsonis the API surface as data, which lets an agent read signatures in place of grepping source.hanki doc FILE --format=jsonemits onehanki-doc-v1envelope on stdout:{"schema": "hanki-doc-v1", path, module_doc, items: [{name, qualified, kind, anchor, signature, effects, doc, has_doctest}]}, every documentable symbol with its rendered signature, written effect row (["io", "throws SendFailed"]), doc prose as Markdown, and whether the doc comment has a runnable example.anchormatches the Markdown output's{#anchor}, and the two views therefore cross-reference. The symbol filter narrowsitemsthe way it narrows the Markdown,hanki doc FILE map --format=json, and a filter miss is an emptyitemsarray at exit 0, text mode exiting 1, mirroringhanki query's null-hover convention. Learning a module's signatures is therefore one structured read and no grep over its source. With no path at all,hanki doc --format=jsonemits the stdlib index instead, onehanki-doc-index-v1envelope,{"schema": "hanki-doc-index-v1", modules: [{name, tier, summary}]}, wheretieris"core"or"extra"andsummaryis the first sentence of the module's header comment,nullfor a module with no header. An index as data is what a tool reads before it fetches anything.hanki doc --find TERM --format=jsonemits ahanki-doc-find-v1envelope,{"schema": "hanki-doc-find-v1", term, modules_searched, hits: [{path, module, match, signature}]}, wherematchis"name"or"prose"andpathis the module-qualified symbol.modules_searchedis there so that a zero-hit answer states its own scope.hanki api-diffclassifies an API change as breaking or compatible, which lets you check the contract before landing it.hanki api-diff FILE [--against REF], defaulting toHEAD, diffs the module's public API surface, every non-_top-level item: signatures, effect rows, struct fields, sum variants, trait methods, impl existence, effect ops and actor handlers, between the working tree and the same path at a git revision, and classifies every delta against the locked rule table indocs/design/api-diff.md. It is mechanical signature analysis: an effect added to a row is breaking for under-permissioned callers (F4),TbecomingResult<T, E>is breaking (F3), a sum variant added breaks exhaustive matches (V1), and an effect removed is a compatible widening (F5); parameter renames and variant reorders are informational. Spelling never false-positives: types compare structurally after qualification,open optionplusOptionbeingoption.Option, and generic and effect-variable names normalize positionally,<T, U>matching<A, B>. Text mode printsbreaking: list.map: effect added to row: [] -> [io] (F4)per finding plus a trailing verdict, and--format=jsonemits onehanki-apidiff-v1envelope,{schema, against, verdict, counts, findings: [{rule, verdict, audience, symbol, kind, before, after, detail}]}, wherebeforeandafterare rendered signatures, and narrating a delta is therefore a template overruleanddetail. The exit code is 1 on any breaking finding, informationals never affecting it, and CI gates on it directly. A path absent at the ref diffs as a first release, all compatible-added. Scope (v1): single-file modules, signature-level only, and a behavior change behind an unchanged signature is invisible by charter.- Property tests catch what a single example misses. Write
test "name"(x: i32, …)with typed parameters and the runner samples each from itsArbitraryinstance over many seeded cases (§20), which is categorically stronger than one chosen example and especially valuable for an agent: a failing case hands you the offending input, shrunk to a minimal still-failing one. The body is the ordinaryassert!, and@property(cases:, seed:)tunes the count and fixes the seed. Reach for one whenever a property should hold for all inputs: a round-trip (decode(encode(x)) == x), an invariant, or commutativity. hanki test --format=jsongives structured test results. Defaulthanki testprints theN passed, M failedsummary andFAILlines. Pass--format=jsonfor onehanki-test-v1envelope on stdout:{"schema": "hanki-test-v1", "summary": {passed, failed, stdlib_passed}, "tests": [{name, status, origin, failure?}]}. A failed test reportsfailure: {location:{path,line,col}, message, rendered, expected, actual, counterexample}. For a comparedassert!(lhs CMP rhs),actualis the structurally-rendered left-hand side andexpectedthe rendered right-hand side, with named fields and variants for structs and sums, and both arenullfor a non-comparison assert or any other failure.counterexamplegives a failing property test's input, its sampled parameters renderedname = valueand joined, shrunk to a minimal still-failing case, and isnullfor a plain, non-property test failure. Therenderedfield is the humanFAILline. A pre-test compile error still reports as text; usehanki check --format=jsonfor structured diagnostics,--format=jsonhere governing the test results alone.@encapsulatedlets pure-context code use a local mutable accumulator. Where adefis externally pure and wants aBytesBuilderor a similar empty-effect-row action internally, building bytes, content-hashing, or a comptime helper, mark it@encapsulated(§6) in place of giving it a!and forcing the effect onto every caller. The checker verifies that the mutation never escapes, and the def therefore remains callable frommeta,where-invariants and other pure code. Reach for it only where a normal puredefwill not do; most pure code needs nothing.???is a typed hole for spec-first development. Write???for any expression you have not filled in yet.hanki checkreports, for each hole, the type the surrounding context expects and the effect budget permitted there, as ahole[H0250]advice line, severityholeon the--format=jsonenvelope. Holes are non-blocking: a holey program type-checks, runs and builds, and a hole reached at runtime diverges likecrash!, printingcrash: hole reachedand exiting 252. An agent can therefore sketch a program's outline, runhanki checkto have the compiler dictate each gap's type and effects, and fill the holes to fit (§6, Typed holes).--deterministicmakes actor programs replayable, which is how you verify concurrent code.hanki run --deterministicandhanki test --deterministicfix the actor interleaving to a seeded schedule: the same source with the same inputs and the same seed gives byte-identical output. That turns output-diffing into a reliable verification for concurrent programs: run once, change the code, run again, diff the bytes.--seed N, which implies--deterministic, explores a different interleaving, and checking a program under several seeds therefore probes schedule-dependent bugs; when one seed fails, that seed is the exact reproduction recipe. A schedule where every actor is blocked is a deterministic deadlock:hanki runreports it on stderr and exits 251 in place of hanging, and underhanki test --deterministicthe deadlocking test records one failure while the suite continues, each test running on a fresh scheduler, and one wedged test therefore cannot poison the next. The caveats are in §6, Debugging and observability: external inputs must be fixed for byte-equality, and OS-level rendezvous between actors is outside the gate's view. Both tiers: an AOT-compiled binary takes the same replay throughHANKI_DETERMINISTIC=1andHANKI_SEED=Nin the environment, and a given seed reproduces the same interleaving onhanki runand in the native binary alike.hanki effectsanswers what--allowwould need, before the program runs.hanki effects PATHprints the program's capability surface, every effect it can perform when run, the set the--allowand--denygate enforces, with the rows that declare each one:io: Logger.log!, main!,net: provide Database. It is declaration-level and sound: by the effect discipline every performable capability is declared in some action, handler orproviderow, the union over those rows therefore bounds the program, and it over-approximates only by dead code that still declares a capability, which a capability audit should treat as live anyway.--format=jsonemits onehanki-effects-v1envelope:{"schema": "hanki-effects-v1", entry, capabilities: [{name, builtin, sites: [{symbol, path, line}]}]}, wherebuiltintells the OS atoms from user-declaredeffects andsitesare the declaring symbols with locations. A pure program prints(no capabilities), or an empty array. The sandbox loop is thereforehanki effectsto see the surface, thenhanki run --allow <that set>to pin it.--sysswitches to the OS seam itself: call-graph reachability over the compiled program from its entry, listing every reachablesys.*intrinsic, one per line, with--format=jsonemitting ahanki-effects-sys-v1envelope{schema, entry, intrinsics, loads_modules}. Its precision contract: it over-approximates, a listed intrinsic possibly being dynamically dead, a fn taken as a value counting as callable and a spawned actor contributing all its handlers, and it never under-approximates within the compiled program. The one hole ismodule.load!-ed source, compiled at runtime and therefore flagged, inloads_modulesand a trailing caveat line, in place of analyzed. Where the declaration view reports a dead[net]action's capability,--sysomits the unreachablesys.tcp_listen!behind it.--denyand--allowsandbox a run by its effects, which is how just-written code is executed safely.hanki run --deny net,fsrefuses to run where the program can perform a denied effect, andhanki run --allow iorefuses unless every effect it can perform is in the allowlist;hanki testtakes both flags identically. Capabilities are the effect atomsio,net,fs_read,fs_write,db,process,time,envandrandom, withfsan alias expanding tofs_readandfs_write, and--allow fs_readtherefore a read-only filesystem, plus any user-declaredeffect.[db]is a distinct intent-level atom for the database seam: the sqlite intrinsics charge[db]and never the general-purpose[fs]seam,fs.read!andfs.write!, and--allow db --deny fstherefore reaches a database and not arbitrary files, while a:memory:database, which touches no file at all, uses[db]where[fs]would be wrong. A file-backed database's own file is opened by the native engine through the[db]seam, and that[db]remains a genuine database-and-not-the-filesystem boundary only because the seam is secured against file-reach escapes,ATTACHto arbitrary paths, loadablereadfileandwritefileextensions, and a permissive VFS; seestdlib/extra/sqlite.hk.--denywins over--allow, an unknown atom is rejected, and the bare default permits everything a program does itself, its own execution being unchanged, with one runtime refinement for programs that load code, below. The check is static, reading the already-computed effect rows and refusing before the program starts, exit code 1 withcapability denied:on stderr, at zero runtime cost, and whole-program, and an effect performed only inside a spawned actor's handler is therefore caught even where it never surfaces inmain's own row. Underhanki testandhanki fuzzthe surface also covers what thetestblocks themselves perform: a test body has no declared effect row of its own, its performed capabilities are collected from the actions it calls and folded into the enforced set, and a test that writes the filesystem is refused under--deny fs_writeasmainwould be, while a test that only reads is still permitted, the precise capability and no blanketfs. An agent can therefore run or test code it just generated under a least-privilege allowance and have the compiler refuse anything outside it. A capability a rift answers is the one exception to a blanket allowance (§21). Wherever any--allowor--denyis given at all, a program that can reach a rift op is refused unless--allownames that effect's atom: aprovidehead row is a checkable claim about what a provider does, and native code has none. The refusal says the atom is provided through a rift into native code, names the crate, and gives the flag to pass. With no flags there is no sandbox claim and the program runs as always, and--denyrefuses a rift's atom like any other.--deterministicrefuses a program that opens a rift at all unless its binding declaresdeterministic = true, an unverified claim by the author, and it therefore has to be written down where a reviewer sees it.
The allowance also travels into the runtime. Every module.load! and module.reload! re-enforces it against the loaded file's declared surface, on the same declaration-level semantics as the launch gate, and refuses a widening load with a catchable ModuleLoadError, and runtime-loaded code therefore cannot escape the sandbox. Loaded code is default-deny. Even with no --allow or --deny, a program that loads modules at runtime pins its load-boundary allowance to its own declared capability surface, and a loaded module may therefore perform only what the host itself advertised; a dependency understating its manifest cannot execute the undeclared effect, capability denied: arriving in the caught ModuleLoadError. A host charges the effects of every Module<T> action it calls (§14), and the host's surface is therefore the ceiling it grants loaded code: to let a plugin do net, the host declares net on the trait method it calls, which also means --deny net on the host stops the whole composition, with no capability-leak hole. This bites only at the load boundary. A program with no module.load! site is left with the unconstrained fast paths, cache and unbounded fuel, untouched, and an explicit --allow is the user's own allowance replacing the derived baseline. Both tiers gate the load boundary. An AOT binary has no launch flags, and hanki build therefore bakes the host's declared surface into it, for a program that can load at all, and the embed loader enforces that baseline at every load! and reload!, the same default the bytecode scheduler derives, from the same declarations. A deployer tightens it without rebuilding through HANKI_ALLOW and HANKI_DENY in the environment, the form HANKI_DETERMINISTIC takes (§22): an allow list retains only the atoms it names that the baseline already grants, and the environment therefore narrows the baseline and never widens it; fs expands as on the CLI; deny wins; and an atom no capability names refuses the run with the CLI's own message, exit 1, in place of tightening nothing. They are read at the first load!, a program that never loads having nothing for the knobs to gate.
Capabilities bound what a run may do, and a companion bound caps how much. hanki run --max-steps N faults the run, exit 250, once N bytecode steps total across every actor are spent, and the run can never execute more than N. Steps are metered in small per-actor grants, and with several actors running at once the fault may therefore fire marginally early, while a single-actor or --deterministic run trips at N. A sandboxed run, any --deny or --allow, applies a generous default budget where --max-steps is unset, and untrusted code can therefore neither perform an undeclared capability nor spin forever. hanki run --max-bytes N is the memory companion, capping total heap allocation, collections, tuples, closures, and the string, bytes and bignum backing bytes those objects hold, at N bytes across the run, faulting the same way, exit 250, with the same sandbox default, and CPU and memory are therefore both bounded. The byte count is a conservative over-approximation, an Arc-shared payload held by several objects being charged once per holder, a security ceiling having to fail early and never late. hanki test takes both --max-steps and --max-bytes too, bounding each test, and a test that exceeds a budget fails, reported directly and never shrunk into a counterexample as a logic failure is. This is the bytecode tier's fuel-sandboxable mode (§22), and the AOT throughput tier omits it.
Where those flags bound a whole run from the CLI, with_budget(bytes, steps) BODY end bounds a nested scope from within the language. It carves a sub-quota from the parent's remaining step and byte budgets, a child never exceeding the parent and its spend debiting the parent, runs BODY under it, and evaluates to Result<T, sys.BudgetExceeded>, where T is BODY's type: Ok(v) on completion, and Err(Exceeded) the moment the sub-quota is spent. Unlike the whole-run ceiling, which faults the run at exit 250, a with_budget overrun is catchable, the host survives an over-budget child and recovers by matching the result, and it is therefore the in-language primitive for running untrusted or unbounded nested work, an embedded interpreter or a user-supplied predicate, under a bounded, recoverable ceiling. It nests: an inner with_budget sub-quotas the outer one, and a normal throw inside the body still propagates through it, budget exhaustion alone being intercepted. It is bytecode tier only, like the flags it scopes, and the AOT lowering rejects it, that tier having no resource metering.
hanki scoreboardshows the package's compiler-verified Guarantees and measured Metrics, the un-gameable facts, before you publish.hanki scoreboard PATHprints a board of independent figures and never a single ranked score, a scalar inviting Goodhart gaming. The Guarantees are facts the toolchain proves: Pure Hanki, no native@intrinsicin the package's own code; Crash-free, nocrash!reachable from the public API, read off theCrasheffect, whichcrash!charges and the effect discipline propagates to every public action that can reach it; the Effect bound and Throw surface a consumer inherits, the union of declared effect rows andthrowstypes; and Docs complete, every public symbol having a runnable example, the same check ashanki doc --check. The Metrics are measured numbers, labelled as such: the Purity breakout,% pure def · % @encapsulated · % actions, with the@encapsulatedshare broken out and not folded in; public-API size; module and line counts; test and property-test counts; and, under--coverage, a measured line-coverage percentage, which runs the package's tests under a recorder, opt-in because it executes code, bytecode-tier and line-only. The public surface is the non-_-prefixed names.--format=jsonemits onehanki-scoreboard-v1envelope,{schema, entry, guarantees, metrics}. Guarantees are derived, and a registry can therefore recompute them from pinned source, which makes searching packages by a provable property, a parser that provably never touches the network, meaningful where a self-reported badge is not. The dependency-mix facts are a planned extension, the closure being the leaf workspace until package dependencies exist.hanki refreshre-syncs the compiler-managed files in a scaffolded project. It is the counterpart tohanki new. Run from a project root, or pass its path, it rewrites the managed set,HANKI.md,HANKI-CARD.md,skills/*and the.claude/skills/adapters, to the current compiler's embedded copies and adds any newly-shipped skill, and a project's reference docs therefore track the toolchain you build with. User-owned files,AGENTS.md,CLAUDE.md,src/main.hk,hanki.config.hkand source, are never touched. The skill sources,skills/*.md, are shared: refresh rewrites only the region between a<!-- hanki:managed:begin -->and<!-- hanki:managed:end -->pair and leaves whatever you wrote outside it, and a project can therefore annotate a skill and retain the annotation across upgrades. Those are reported asupdated, the claim being checked and not hoped for.hanki newwrites those markers when it scaffolds the file, the convention is therefore established by whichever command creates it, and an annotation added before a project's first refresh survives that refresh too. A file with no markers, every project scaffolded before this, is adopted whole on the next refresh, which writes them in. Everything else in the managed set is replaced wholesale and the command retains no copy, and the report therefore calls thatoverwrittenand notupdated: it cannot tell your edit from ordinary staleness after a toolchain upgrade, and says the word that is true of both. The two reference docs are vendored verbatim and the.claude/adapters open with frontmatter that must come first, and neither therefore takes markers; keep additions to those in a file of your own. A mangled marker pair, one half, a duplicate, or reversed, is refused and the file is left alone in place of repaired by guesswork. It is idempotent, printingAlready up to datewhen current, and--dry-runlists what would change, in the same words, without writing.