docs · reference
Embedding the Engine#
Hekma is a library first and a CLI second: the hekma binary is itself just an embedder that drives the engine's public Rust facade. A hosting platform can do the same — register agents, configure them, enforce token and dollar budgets, subscribe to lifecycle events, and supervise processes, with no CLI, no TTY, and no prompts. This page is the quickstart; the flow-level guarantees are covered in Architecture.
Adding the dependency#
Depend on the released version:
[dependencies]
hekma-engine = "0.6"Prefer to track main between releases? Pin the repository to a full-length commit SHA, never a branch: a pinned rev makes your build reproducible and upgrades deliberate.
[dependencies]
hekma-engine = { git = "https://github.com/Ktesio/hekma", rev = "<full commit SHA>" }Prefer to track main between releases? Pin the repository to a full-length commit SHA, never a branch: a pinned rev makes your build reproducible and upgrades deliberate (the engine's public surface is CI-guarded against breaking changes between freezes, but a moving target is still a moving target).
Which SHA to pin: the engine's current surface freeze is the 2026-09-14 merge commit — the CI semver gate guards the public surface against that exact baseline. Pin any commit at or after it; the newest main commit you are comfortable with is the right default. To fetch a current full SHA to pin:
git rev-parse origin/main(paste the full 40-character output as your rev). The published and in-repo forms compile against the same facade — see the changelog banners for what moved in 0.2.0 and 0.3.0 (EngineError::ResumeUnsupported, then the 0.3.0 detached-start surface: EngineError::DetachRefused, SpawnRecord.detach, and the ProcessBackend::adopt signature — exhaustive matches over EngineError need each new arm). The ACP backend (the acp builtin kind, shipping as 0.6.0) grows the surface additively the same way: EngineError::AcpTurnInFlight is a new exhaustive-match arm (exit-code row 4 — a second prompt while an acp turn is in flight), SpawnSpec gains the pipe_stdout field (the ACP transport owns the child's stdout; exhaustive struct literals need it), and ProcessBackend::take_stdin/take_stdout are NEW DEFAULTED trait methods (existing backends compile unchanged). The new builtin acp kind registers like any native kind and needs no contract negotiation.
Two things to know before depending: the engine's minimum supported Rust is 1.96.1 (the workspace rust-version; any toolchain at or above it builds), and Hekma is fully open source under the Apache License 2.0 — free for commercial use, with an explicit patent grant.
Only hekma-engine is needed. It is a normal Rust dependency — the engine never touches your TTY, never reads stdin, holds no global process state, and several engines can live in one process side by side, each rooted at its own state directory.
The facade surface#
Open an engine with Engine::open(base) and either call its async methods on your own runtime or take engine.blocking() for a synchronous view — the same surface hekma uses. The capabilities you will reach for first:
| Facade | Purpose |
|---|---|
Engine::open(base) | Open (or create) an engine rooted at a state directory; None uses the OS default. |
register / register_with_adapter | Register an instance under a built-in adapter kind or a manifest (adapter.toml) directory. The built-in kinds include hermes and acp (any Agent Client Protocol v1 agent — set the acp.command/acp.args config keys; no contract negotiation). |
set_config / effective_config | Write and read the unified configuration (budgets, rates, model keys) with per-leaf provenance. |
start / stop / pause / resume | Drive the lifecycle; stop takes a graceful-shutdown window and kills the whole process group. |
start_detached / Blocking::start_detached | Spawn an instance that outlives your engine handle; refused with EngineError::DetachRefused for engine-observed instances. See the host duty below. |
subscribe / Blocking::subscribe | Receive the event stream (below). |
resync_events / Blocking::resync_events | Backfill the committed events a subscriber missed (below). |
with_diagnostics / Blocking::with_diagnostics | Route the engine's two stderr diagnostics into your own writer (below). |
fleet / instance_status | Read per-instance rows — state, usage, budget remaining, metering source — what hekma agent list renders. |
budget_breach_events / transition_events / read_agent_log | Query the durable records directly (a subscribe sees only later commits; the query APIs reach the past). |
send_input / attach_memory / detach_memory | Interaction and memory wiring, where the adapter declares support. For an acp instance send_input dispatches one ACP session/prompt turn and returns immediately (the turn streams asynchronously); a second prompt while a turn is in flight is refused with EngineError::AcpTurnInFlight. |
Every method returns a typed Result — the engine reports partial failures with a reason and a remediation instead of panicking.
The detached-start host duty#
start_detached spawns a supervised agent whose handle is disarmed: your engine's exit — and the exit of every later engine that re-adopts it from the write-ahead record — leaves the process alive, until an explicit stop. That survival is the feature, and it has a cost the engine states but cannot itself cover: between engine lifetimes there is no crash detection, no budget enforcement, and no event delivery — supervision is command-scoped. A host that starts agents detached owes its operators the same honesty: keep the enforcement window visible wherever the option is offered, and remember that a detached instance's usage ledger only advances while some engine holds it. start_detached is refused outright (EngineError::DetachRefused) for engine-observed instances, because their loopback metering listener lives inside the starting engine and would strand the agent's base_url on a dead port — start those without detaching.
The event bus#
engine.subscribe() hands you a receiver over a bounded, ordered event bus. Four rules cover the whole contract:
- Subscribe before it happens. A receiver observes only events committed after it subscribed, in commit order, per-instance FIFO. Anything earlier is readable through the query APIs.
- Payloads are versioned structs. Every event carries a
schema_versionand is one of: a lifecycle transition, a budget breach, or a committed usage measurement — the exact wire shapeshekma --jsondocuments. - A slow subscriber never stalls supervision. The bus is bounded; if you fall more than its capacity behind, your next receive observes
Laggedand resynchronizes at the tail — the dropped events stay readable in the durable logs. Drain promptly or polltry_recvon your own cadence. - Delivery is at-most-once in the crash window — and recoverable. Events are appended to the durable record first, then published; a process crash between the two loses that one event from the stream. The durable record stays complete, and
resync_events(below) heals the window in one call. Treat the stream as a live notification surface, with the resync as your gap remedy.
Healing the crash window: resync_events#
Rule 4 above leaves a gap: events committed while nobody was subscribed (or while your subscriber's process was down) never reach the stream. The engine ships the remedy — one call reads the instance's COMMITTED event records (the same truth the query APIs serve: transitions, breaches, ledger rows) and returns them as the exact event payloads the live bus delivers:
use hekma_engine::{Blocking, ResyncCursor};
// 1. Subscribe live FIRST — the gap-free order. Every commit from this
// moment reaches the stream, whatever the backfill does afterwards.
let mut events = facade.subscribe();
// 2. …then backfill everything committed before it. The backfill overlaps
// the live stream, so drop the overlap yourself: skip each family's
// first `cursor.<family>` backfilled records against your own position
// bookkeeping (or key on the records' own identity fields).
let batch = facade.resync_events("my-agent", ResyncCursor::START)?;The contract, in five rules:
- Committed truth only. A read-side helper over the same durable records the query APIs return — the bus is untouched. An event whose append failed never appears in a backfill either.
- Ordering is exact per family; the combined order is your choice, with named tradeoffs. Transitions come in
instance.logorder, breaches inbreaches.logorder, usage updates in ledger commit order — the same orders the stream guarantees. Across families the batch is family-major (transitions, then breaches, then usage): the durable record carries no global cross-family sequence, and the engine does not fabricate one.- Subscribe first, then resync is the gap-free order: every commit after the subscribe reaches the live stream by construction, and the backfill merely overlaps it — you see duplicates, never gaps, and you dedup the overlap per family with your own cursor bookkeeping (the sample above). Gap-sensitive hosts should use this order.
- Resync first, then subscribe has no seam duplicates — the backfill is precisely the prefix and the live stream precisely the suffix — but it is NOT gap-free: a commit landing between the
resync_eventscall returning and yoursubscribe()is delivered by neither. Use it only across a quiescent agent (stopped/paused, no traffic) or accept the window consciously.
- Cursor-based and idempotent. The returned batch carries a
ResyncCursor; pass it to the next call and the already-consumed prefix is skipped, so re-running a resync never re-delivers. Persist the cursor across your own restarts if you like (it serializes snake_case). A cursor below a family's committed count backfills the suffix; a cursor ABOVE one (the log was truncated or the ledger rotated away) is a typed error naming the family and both counts — never a silent clamp, which would re-deliver from zero or silently skip records. Resetting toResyncCursor::STARTis your deliberate choice. - Crash-recovery read posture, with the skip surfaced. The helper is called most often right after the crash it heals — and that crash can tear the log's trailing append. One unparseable trailing line per log is skipped — and only when it carries the torn-append signature (the file does not end with a newline, the mark of a write cut mid-append) — and the skip is never silent: the batch's
torn_tail_skippedflag is set, because the engine's next append fuses onto the torn fragment and the skipped line may be carrying a good post-crash record you did not receive. Every other malformed line is a typed error worth investigating: an interior line, a newline-terminated trailing line (corruption, or exactly that fused line), or a trailing line that is valid JSON of the wrong shape (the wrong file). - Per-instance — and per-family honest.
namescopes the read; a Fleet-wide backfill is your loop over instances. An unregistered name failsNotFoundrather than reading as a silent empty backfill. And the batch is per-family exact, not a cross-family snapshot: the three family reads (transitions, breaches, ledger) are sequential, so a commit landing between them skews the families relative to each other. Each family's slice stays exact and cursor-lossless — the next call picks up exactly the stragglers — but if you need one coherent point-in-time view, make the agent quiescent (stop/pause) across the read.
The diagnostic sink#
The engine writes exactly two operational diagnostics — a DC-10 notice when an attached filesystem memory backing cannot be delivered to the agent, and an enforcement breadcrumb when a budget-breach action (pause/stop) could not be honored. With no sink installed they go to stderr, byte-for-byte as they always have; nothing is written to stdout, ever. A host that owns its stderr (for a daemon, a GUI, a log pipeline) can route both into its own writer:
use hekma_engine::{DiagnosticSink, Engine};
use std::sync::{Arc, Mutex};
// Any std::io::Write works — a file, a channel, an in-memory buffer.
let sink: DiagnosticSink = Arc::new(Mutex::new(Box::new(std::io::sink())));
// Either install at open (the airtight form — in place before any
// supervision work, including orphan adoption and the crash reaper):
let engine = Engine::open_with_diagnostics(Some(state_dir), sink.clone())?;
// …or install/rotate on an already-open engine:
engine.blocking().with_diagnostics(sink);The contract, in five rules:
- Exact texts. Each diagnostic arrives as one full line — the same
[hekma]-prefixed text stderr would have received,\n-terminated. A sink that mirrors its input reproduces the default output byte-for-byte. - Opt-in, additive — and one-way. Installing nothing changes nothing; the default path is pinned by CI as byte-identical to the historical engine. Installing REPLACES whatever sink is installed (that is how you rotate writers mid-flight; the outgoing writer is flushed before the swap so its buffered bytes are not lost), but there is no uninstall back to the stderr default — a host that wants the default back re-opens the engine, or installs its own writer that emits to the process's stderr.
- Thread-safe by construction. The sink is an
Arc<Mutex<Box<dyn Write + Send>>>, so the engine can emit from any supervision thread and you can clone theArcto share one sink across engines. Diagnostics are rare — the lock is never a hot path. - Never re-enter the engine from the writer. Emissions happen while the engine's supervisor lock is held; a
writethat calls back into the engine would deadlock. Forwarding the line to your own channel or lock is fine. - Best-effort, like the diagnostics themselves. A write error is swallowed — and so is a PANIC in your writer's
write/flush(the engine catches it: a host bug must never unwind through supervision, and the same sink keeps receiving later diagnostics) — so supervision never fails or blocks on a broken sink. No diagnostic is ever the durable record of anything — the transition, breach, and usage logs remain the authoritative copies, readable via the query APIs.
The quickstart example#
A complete, runnable host lives at crates/hekma-engine/examples/embedding-quickstart.rs. Run it from the repository root:
cargo run -p hekma-engine --example embedding-quickstartIt is deliberately dependency-free — no hekma, no test fixtures, no helper crates, not even tempfile — so it shows exactly what a host depends on. The seven legs, matching the numbered steps in the file:
- Open: a scratch state root (a per-process directory under the OS temp dir, cleaned up best-effort at the end) and
Engine::open(Some(root))with the blocking facade. - Register a manifest adapter: the example writes a minimal
contract_version = "1.0.0"adapter.toml(see the manifest reference and the Adapter Contract) whose[lifecycle.start]command re-executes the example binary itself as a stand-in agent — a real host points that field at its own agent executable. (The engine's built-in kinds, such ashermes, register withAdapterRef::Nativeinstead.) - Configure one budget key —
budget.tokens.cumulative = 100000— and assert theeffective_configread-back (value and provenance layer). - Subscribe before starting, so the transition events are observable.
- Start: the engine spawns the manifest's
[lifecycle.start]command and reachesrunningbefore the call returns. - Observe: drain the committed events (
Laggedresyncs at the tail and keeps draining), then assertinstance_statusisrunningand thefleetrow carries the configured budget ceiling. - Stop with a five-second graceful window, then remove the scratch root (best-effort).
CI compiles the example on all three OS legs and runs it hermetically on ubuntu on every push — the quickstart cannot silently rot.
The boundary is the guarantee#
What a host can reach is enforced by the compiler, not by convention: the engine's private modules are Rust-private, so if you cannot name it, you cannot call it — a host compiles against exactly the same public API hekma does. Four instruments keep that statement honest:
- The dependency-shape gate — CI's
boundaryjob allowlists the internal edges of the shipped CLI's graph (cargo tree), sohekmacannot quietly grow a dependency on anything but the engine, the adapter-contract types, and the built-in hermes adapter; a future internal crate fails the gate automatically (the workflow). - The facade audits — the embed-clean suites prove
hekmaconsumes only the blocking facade (no async APIs, no runtime of its own) and that every async method has a blocking counterpart, with no TTY, prompt, or global-state escapes. - The library-alone flow proof — a host test drives the full register → configure → cap → start → breach → pause → stop journey through the facade alone and shares its assertions with the CLI suite, proving the library path and the CLI path behave identically (the host test).
- The dependency-audit checkpoint — when reviewing or bumping HTTP-stack dependencies (
hyper/hyper-util/reqwest-family, and the TLS leghyper-rustls/rustls/tokio-rustls/webpki-roots), check the tracing exposure:hyper-utillinkstracing, and its connection-pool events would carry the upstream host:port and timing IF a globaltracing-subscriberwere ever installed. The engine ships NO subscriber (events are no-ops) and hyper's own tracing feature is OFF, so today nothing is emitted; the exposure never carries theAuthorizationheader, body, or key. The new TLS crates keep the same silence by configuration: hyper-rustls'sloggingfeature is OFF (no tracing surface linked), rustls logs only through thelogfacade at debug/trace levels — and the engine installs no logger, so those calls are no-ops. The checkpoint: any future story that installs a global DEBUG/TRACE subscriber must re-audit what the HTTP and TLS stacks log at that level before it ships (a model-call endpoint is operator-sensitive context, even without credentials). - The semver gate — CI diffs both public crates' surfaces against their freeze baselines, so a breaking change cannot land unnoticed.
Availability#
Published (2026-09-16): hekma-engine 0.4.0, hekma-adapter-api 0.2.0, and hekma-adapters-hermes 0.2.0 are live on crates.io — the renamed line continuing the pre-rename ktesio-* history (ktesio-engine 0.1.0–0.3.0, ktesio-adapter- api 0.1.0, ktesio-adapters-hermes 0.1.0; first release v0.7.0, 2026-09-09). The old names each carry ONE final deprecation-shim version (ktesio-engine 0.3.1, ktesio-adapter-api 0.1.1, ktesio-adapters-hermes 0.1.1) that re-exports their hekma-* successors and will never publish again. The crates are open source under the Apache License 2.0 (free for commercial use; the explicit patent grant is part of the license).
The publish runbook's historical HELD state is retained in the release process decision log.