Configuring heartd#
heartd reads one TOML file. Relative paths are resolved against that file. examples/pump-station/heartd.toml is a minimal example, and unknown keys are rejected.
Sections#
| Section | Key | Default | Meaning |
|---|---|---|---|
system | card | required | HEART Card, as JSON |
instance | required | Non-identifying instance label | |
custom_dir | none | Directory of custom definitions | |
rules | none | JSON array of Tending rules | |
server | bind | 127.0.0.1 | Address. Anything but loopback needs TLS |
port, fallback_port | 43278, 7685 | Standard and fallback | |
tls_cert, tls_key | none | PEM files | |
unix_socket | none | Also listen on this socket | |
max_connections | 32 | Further connections are dropped | |
rate_per_sec | 50 | Per caller, with a burst of twice this | |
max_body_bytes | 262144 | Larger bodies get 413 | |
host_socket | none | Socket for the product's own events, mode 0600 | |
trust_gateway_headers | false | Trust a Gateway's caller headers on unix_socket, from gateway_uid only | |
gateway_uid | none | The Gateway's operating-system user. Required with trust_gateway_headers | |
gateway_public_key | none | Base64 of the Gateway's Ed25519 key: the key this system pins. Trusts its signed caller headers over TLS, its fleet approvals (presence method fleet, API Standard 13.3) and the commands it signs on the command channel. The Gateway prints it at startup as publicKey | |
auth | issuer | none | Required iss claim |
public_key | none | Base64 of the raw Ed25519 public key | |
owner_key_file | none | The Basic credential profile (API Standard 8.2), only for a Card whose level is basic. At first start heartd makes an owner key (256 random bits), prints it once on standard error as {"heart-owner-key": ...}, and keeps only its SHA-256 here, mode 0600. Authorization: Bearer <owner key> is then the owner: human, private, the scopes of the owner role. heartd owner-key <config> makes a new key and prints it; the old one stops at once. A release, transfer or factory reset makes a new one too. The key is never sent anywhere | |
storage | state_file | required | Written atomically, mode 0600 |
identity | key_file | identity.key beside the state file | The system's Ed25519 identity key, created on first start, mode 0600. Signs the Card |
presence | rp_id | none | WebAuthn relying party ID, shown as card.presence.rpId. With [presence], the Card offers module presence. Without it, nothing can be approved |
origins | none | Origins of the approval page | |
[[presence.credentials]] | none | principal, id, alg (ES256 or EdDSA) and public_key (base64url) of each approver's passkey. A seed: they are listed by GET /heart/v1/presence/methods with source: "config", beside passkeys enrolled with POST /heart/v1/presence/methods, which the state file keeps | |
core | log_capacity, store_capacity | 4096, 512 | Hard limits |
escalate_after_sec, escalate_to | 900, none | Escalation | |
daily_budget_usd, max_call_usd | 1.0, 0.05 | AI spending limits | |
allowlist | empty | write actions that may run unapproved: entries {target, initiators, envelope} as in heart.control (Core Standard 7.4). A bare string is an entry for rule only | |
autonomy_max | 5 | The starting autonomy ceiling, 0 to 5, as set by PUT /heart/v1/tending/autonomy. The mode follows from the current level: 0–2 observe, 3 suggest, 4–5 act | |
autonomy_level | 4 | The promised Autonomy Level. A brain's Proposal uses the allowlist only at 5 | |
approval_timeout_sec | 86400 | How long an Action waits for approval (at least 60) | |
fleet_physical | false | Accept a fleet approval from the pinned Gateway for a physical Action too, when the fleet command was approved as physical. Shown as fleetPhysical in heart.control. Set it on the device itself | |
max_workers | 8 | Background effects at once | |
model | gateway_url | none | Reasoning Gateway. Omit for no model |
token_env | none | Environment variable holding its token | |
tier, timeout_ms | cloud, 20000 | ||
pins | empty | [{purpose, provider, model, pinnedAt}] | |
webhooks | signing_key_file | none | Base64 of a 32-byte Ed25519 seed |
extra_ca | none | Extra PEM certificate to trust | |
allow_private_sinks | false | Allow sinks on private addresses | |
supervisor | heartbeat_file | none | Needed by heartd supervise |
hung_after_sec | 15 | Restart when the heartbeat is older | |
max_restarts, window_sec | 5, 300 | Then give up and leave the Core off |
| uplink | gateway_url | none | Join this Gateway | | | token_file | required | Token used to join, with scope heart.send | | | extra_ca | none | Extra certificate to trust for the Gateway | | | push_clearance | internal | Highest level sent to the Gateway | | | every_sec | 30 | How often to announce again | | | commands | false | Keep a command channel (API Standard 9.3): the Card is pushed without endpoints, as for a device behind NAT, and heartd long-polls the Gateway's inbox with Prefer: wait. Each command must be signed with [server] gateway_public_key, be for this system, be at most 60 seconds old and new; anything else is dropped unanswered and recorded as security.command.refused. A command runs through the API and the gate as a request from the Gateway would, and its answer goes back as a heart.reply. Needs gateway_public_key | | | poll_wait_sec | 25 (1 to 30) | How long one long poll waits for a command |
Optional modules served by the Core#
Each of these sections offers one module of API Standard 13.1, and adds it to the Card's modules. Without the section the module's operations answer 501. Every store is bounded; the ceiling is given after the default.
| Section | Key | Default | Meaning |
|---|---|---|---|
inventory | [[inventory.components]] | empty | The parts: id, kind (as in component.schema.json), and optionally name, parent, version, vendor, depends_on. GET /components lists parents first. A part's status comes from its open alerts |
[[inventory.dependencies]] | empty | Outside services and systems, the same fields, and probe = "host:port": heartd connects to it, and a change of answer raises or resolves connectivity.dependency-unreachable for it | |
probe_every_sec, probe_timeout_ms | 30, 2000 | How often, and how long one try may take | |
config | [config.settings.<name>] | empty | Each setting: type (string, number, integer or boolean), and optionally minimum, maximum, enum, min_length, max_length, pattern, default, title, description and privacy (public, internal (default), private or never-share). The value in values takes the setting's level. A never-share setting is never returned and is not changed through the API; a caller changes only settings at or below their clearance. A value outside minimum/maximum answers 422 out-of-range; anything else wrong, 400 |
history | 20 (1000) | Revisions kept. GET /config/history sets gap: true once older ones are gone, and POST /config/rollback to one of them answers 404 | |
file | none | The current values, as JSON, written atomically (mode 0600) at start and on each change, every setting included, for the product to read | |
apply_command | none | Program (no shell) run to apply a change, with HEART_CONFIG_VALUES (JSON) and HEART_CONFIG_REVISION. A non-zero exit refuses the change (502 native-failed) and no revision is made | |
timeout_sec | 30 (600) | Longest apply_command may take | |
telemetry | [[telemetry.metrics]] | empty | Metrics: name (a vital or property), kind (gauge, counter or histogram), unit (UCUM), privacy of the points (default internal, or the property's own). A never-share metric is never kept |
include_all | true without metrics | Also keep every vital and numeric property as a metric | |
points_per_metric | 1440 (100000) | Points kept per metric, oldest dropped first | |
sample_every_sec | 10 | At most one point per metric in this time; a newer value replaces it | |
max_metrics | 64 (1024) | Metrics kept | |
log_capacity | 1000 (100000) | Log entries kept in memory. Every Rhythm event is also a log entry (attributes.source: "heart"); lines from the host are source: "host" | |
max_line_bytes | 2048 (16384) | Longer messages are cut | |
log_file | none | A log file to follow, like tail -F. ERROR, WARN, CRIT and FATAL in a line set its severity | |
journald_unit | none | A systemd unit whose journal to follow, through journalctl -o json | |
diagnostics | [diagnostics.tests.<name>] | empty | Each self-test: command (program, no shell: exit 0 passes, each printed line is a finding, at most 20) or native (a native request: it passes if the device answers), with description, disruptive (then write: it passes the gate, needs an Idempotency-Key, and answers with its Action), cancellable (heartd stops the program when the Action is cancelled, or by Stop) and timeout_sec (30, at most 600). Names are lower-case letters, digits, _, . and -, not bundle |
runs | 50 (1000) | Past runs kept | |
bundle_max_bytes | 262144 (16 MiB) | Largest support bundle. POST /diagnostics/bundle takes {since, until, include, maxBytes}; sections are logs, rhythm, config, components, diagnostics, metrics and pulse, each filtered for the caller's clearance (never above private). Oldest log lines and Rhythm events go first when it is too large | |
software | [[software.packages]] | heartd only | What is installed: name, version, kind (firmware, os, application, model or library). heartd lists itself too. GET /software shows them to internal callers |
channel, boot_slot | none | Shown in GET /software. A manifest for another channel is not offered | |
sbom_file | none | SPDX or CycloneDX JSON (at most 4 MiB), read at start and served by GET /software/sbom to internal callers; without it, 404 | |
update_source | none | A directory of *.manifest.json files, or an https:// URL whose index.json lists them (below). Needs update_keys and install_command | |
update_keys | empty | Base64url Ed25519 public keys. A manifest not signed by one of them is listed with signed: false and never installed (403 signature-invalid) | |
install_command | none | Program (no shell) run with HEART_UPDATE_FILE (the verified payload), HEART_UPDATE_PACKAGE, HEART_UPDATE_VERSION, HEART_UPDATE_FROM and HEART_UPDATE_MANIFEST. Exit 0: installed; exit 10: installed, in effect at the next start (awaiting-reboot); anything else: failed, and rollback_command runs | |
rollback_command | none | Program run with HEART_UPDATE_PACKAGE and HEART_ROLLBACK_VERSION: after a failed install, and for POST /software/rollback, which is offered only with it | |
install_timeout_sec | 600 (86400) | Longest an install or rollback may take | |
updates_kept, max_update_bytes | 20 (1000), 256 MiB | Update records kept; largest payload accepted | |
staging_dir | updates beside the state file | Where payloads are fetched to, mode 0600 | |
artifacts | dir | artifacts beside the state file | Where files are kept, one per artifact ID, mode 0600. Files nothing refers to are removed at start |
max_count | 100 (10000) | Files kept. A full store answers 409 conflict | |
max_total_bytes | 64 MiB (4 GiB) | Bytes of all files together | |
max_file_bytes | 8 MiB (256 MiB) | Largest file. Uploads are JSON with the file in base64, so [server] max_body_bytes must be about a third larger | |
schedules | max | 50 (1000) | Schedules kept. A single-time schedule that has run makes room for a new one |
min_interval_sec | 60 (604800) | Shortest time between two runs of one cron schedule. Faster answers 422 out-of-range | |
runs_kept | 10 (100) | Runs listed per schedule | |
grace_sec | 300 (86400) | A run this much later than planned, because heartd was not running, is recorded as missed and not carried out | |
privacy | consent_required | ["model-provider"] | Kinds of recipient (model-provider, subscriber, peer, gateway) that receive private data only while a consent naming that kind, or that recipient, is granted. Withdrawing the consent filters the next transfer at once |
retention_days | none (36500) | Longest Memory keeps an item. PUT /valve/policy can change it | |
consents_kept | 200 (10000) | Consents kept. Withdrawn and expired ones make room first; with only granted ones, a new one answers 409 conflict | |
erasures_kept | 100 (10000) | Records that an erasure happened, oldest dropped first | |
export_max_bytes | 1 MiB (16 MiB) | Largest export. The oldest entries of the largest part go first, and truncated says so | |
export_expires_sec | 604800 (7776000) | With [artifacts], an export is stored as an artifact of kind export that expires after this. A full store answers the package itself | |
search_logs | true | An export also lists log lines (module telemetry) that name the person | |
ai | modes | ["org-proxy"] | org-proxy and/or byok. With [brain], only org-proxy |
providers, regions | empty (any) | Providers and regions allowed. A pin for another provider is answered from rules, and cannot be set | |
uses | all | What a model may be used for: explain (/ask), serve, propose, plan, chat. Another use is answered from rules | |
monthly_budget_usd | none | Model spending per calendar month (UTC). The daily budget and the most per call come from [core] daily_budget_usd and max_call_usd | |
allow_fallback | true | Sent as modelPolicy.allowFallback | |
usage_days | 90 (3660) | Days of model use listed by GET /ai/usage | |
memory | max_items | 500 (100000) | Items kept. When full, the oldest short-term item goes first. Each message received at /inbox is a short-term item at its own level (at least internal); how each Trial ended is a long-term item |
max_summary_bytes | 2048 (16384) | Longer summaries are cut. Secrets are removed first, as in logs | |
short_term_sec | 604800 (31536000) | How long a short-term item is kept | |
evolving | suite, regression_command | none | The regression suite's name, and the program (no shell) that runs it on a candidate model with HEART_REGRESSION_SUITE, _PURPOSE, _PROVIDER, _MODEL and _VERSION. Exit 0: passed; a line score: 0.93 gives the score. Without them no pin can change (409 conflict) |
timeout_sec | 300 (3600) | Longest the suite may take. heartd stops it when the Action is cancelled, or by Stop | |
purposes | explain, serve, propose, plan, chat | Purposes a model can be pinned for | |
candidates_kept, feedback_kept | 20 (200), 500 (10000) | Candidate models listed by GET /evolution; ratings kept from POST /feedback | |
security | audit_capacity | 1000 (100000) | Audit entries kept, oldest first to go. Each is chained to the one before by SHA-256, and GET /audit says gap: true once older ones are gone. Every request that may change something is logged, with any refused read and every denied, refused or expired Action; bodies never are |
secure_boot, certificate_expires_at | none | Shown in GET /security when given. tls there is true when [server] tls_cert is set | |
platform | model, cpu, memory_bytes, serial, os | none; memory_bytes from /proc/meminfo, os from /etc/os-release | GET /platform. serial is private |
power_source, network_kind, clock_source | none | mains, battery, solar, poe or other; the link kind; the time source. The product reports power, network and clock as they change on the host feed (below) | |
clock_synced_file | none | A file whose existence means the clock is synchronised, such as /run/systemd/timesync/synchronized. Checked every minute | |
provisioning | claim_code_file | required | The claim code that came with the device (at least 16 characters), readable only by heartd. After a claim, a transfer, a release (POST /provisioning/release) or a factory reset heartd writes the new code there, mode 0600: it is shown nowhere else |
max_attempts, lock_sec | 5 (20), 3600 (60 to 86400) | Wrong codes before claims are refused (429), and for how long. Kept across restarts | |
local_only | false | Take a claim only through loopback or the Unix socket (others get 403). The code travels in the body, and before the claim the client cannot check the server, so turn this on unless the product claims by a key exchange such as SPAKE2+ first (API Standard 10.1 rule 3) | |
peers | max | 100 (10000) | Peers kept. A new one when full answers 409 conflict |
access | max_grants | 100 (10000) | Grants kept (module access, API Standard 13.2 rule 21). Each gives one principal a role: owner, installer, maintainer, operator or viewer. While any grant exists, every caller (token, forwarded or on the command channel) is held to its own: the scopes its token and its role both allow, the lower clearance, and peer, public, no scopes without one. Granting or widening is destructive, narrowing or removing write; the first grant gives owner, and the last owner grant stays. A grant's certificate.sanUri is kept and listed as the mutual-TLS table; heartd itself does not terminate mutual TLS |
batch | max_items | 100 (100) | Requests in one batch. The Card's limits.maxBatch also bounds it |
native | protocols | required | Protocols open to passthrough: modbus-tcp. process never is |
targets | required | Targets open to passthrough, such as tcp:127.0.0.1:502:1. Any other answers 404 | |
allow_writes | false | Without it passthrough only reads, and a write answers 409 guard-failed | |
[[native.protected]] | empty | target and register never written, beside every address a custom definition marks physical or guards | |
timeout_ms, max_request_bytes | 2000 (30000), 4096 (65536) | Longest time and largest request | |
reasoning_gateway | ceiling | internal | Serve POST /heart/v1/reason (API Standard 7.2). Context above this level answers 403 valve-blocked |
daily_budget_usd, max_call_usd | none | Each caller's spending per UTC day, and the most one call may cost. A call that could pass either is not made (429 budget-exceeded) | |
max_output_tokens | 1024 (32768) | Output tokens one call may ask for | |
[[reasoning_gateway.upstreams]] | required | Tried in order (by price with prefer: cost): name, provider, model, api (openai for an OpenAI-compatible /chat/completions, or anthropic for /v1/messages), base_url (https; http://127.0.0.1: only with allow_loopback_http), api_key_env or api_key_file, region, input_usd_per_mtok, output_usd_per_mtok, timeout_ms (30000). heartd refuses to start if a named key is missing |
Updates follow the SUIT architecture (RFC 9019): a signed manifest describes the payload, and the Core verifies the signature, then the payload's size and SHA-256, before anything is installed. Installing is destructive: a person proves presence with the request, or another person approves it. A manifest file is:
{"manifest": {"name": "station-fw", "version": "3.3.0", "kind": "firmware", "sizeBytes": 524288,
"sha256": "<hex of the payload>", "payload": "station-3.3.0.bin", "channel": "stable",
"notes": "Fixes the level sensor", "security": false},
"signature": "<base64url Ed25519 signature over the RFC 8785 JSON of manifest>"}
payload is a file name in the update source, without ... While the payload downloads the update can be cancelled, and Stop cancels it; once it installs, it cannot.
Schedules run as their author. Each run is a new Action, with scheduleId, that passes the gate as the author at that moment: a destructive run waits for a person, a peer's or AI's run follows the mode and the allowlist as they are then, and while autonomy is stopped no run that changes anything starts. A schedule made by ai, peer or rule is proposed until a person approves it. cron is read in UTC. A lifecycle request with delaySec becomes a schedule.
Privacy. PUT /valve/policy and PUT /ai/policy replace the whole policy. Tightening the Valve policy is write and runs at once for a person; loosening it (a higher llmCeiling, a lower or removed classification, a longer retention) and any change to the AI policy are destructive, so a person proves presence with the request, or another person approves it. The Valve policy starts from the Card's valve and retention_days; once changed through the API it is kept in the state file, until the Card's valve changes. llmCeiling: private needs a granted consent for model-provider. An export or erasure is asked by the person (sub of the token), or by a person with heart.admin who says in authority what they act under. Erasing removes the person's Memory, consents, ratings, receipt links and stored exports, writes [erased] for their identifier in Actions, Rhythm and logs, and keeps only a record with a keyed digest of the identifier.
Keys, owners and trust. POST /security/rotate is destructive. heartd makes a new identity key and a rotation statement signed with the old one, keeps the old key in identity.key.previous and in the key set for 7 days, and sends the statement as Heart-Key-Rotation with every Card, and as heartkeyrotation when it pushes its Card to a Gateway, which then moves its pin. Another rotation within those 7 days answers 409. A claim takes the code from the box, records ownerKey and issuer, and from then on heartd accepts only tokens that key signs: the [auth] key no longer works. A transfer moves that to the new owner at once, and removes presence methods, grants, subscriptions, consents and schedules, and cancels the Actions waiting for approval. A release does the same without naming anyone, and the system is claimable again. A factory reset also erases personal data (Memory, consents, ratings, receipt links, erasure records and every other Action) and removes peers; a decommission does the same, and the system can never be claimed again. Each makes a new claim code (none after a decommission), and on a Basic system a new owner key. Trusting a peer and changing the allowlist pass the gate like any change to authority; a peer's messages stay data. A batch's items each pass every check as if sent alone; an item that needs an Idempotency-Key uses the batch's key with . and its position.
The Reasoning Gateway keeps the owner's instructions in the system prompt and puts every context item and the conversation in marked blocks, escaped so that they cannot close their block. Provider keys are added by heartd only; a provider's error text is never passed on. GET /security/attestation?nonce= answers evidence signed by the identity key.
Model pins. PUT /evolution/pins/{purpose} is destructive; the model is pinned only if regression_command passes, and the Rhythm records evolving.model.adopted or evolving.model.rejected. POST /evolution/rollback (write) returns to the pin before the last change. Pins set under [model] are the starting point.
Secrets never reach a log or a bundle: the values of private and never-share settings, key=value and key: value pairs whose key names a password, secret, token, API key or authorisation, and the word after Bearer are replaced by [redacted] before a line is kept.
[inventory]
probe_every_sec = 30
[[inventory.components]]
id = "controller"
kind = "board"
name = "Pump controller"
[[inventory.components]]
id = "pump"
kind = "actuator"
parent = "controller"
[[inventory.dependencies]]
id = "plc"
kind = "network"
probe = "192.168.1.20:502"
[config]
file = "settings.json"
apply_command = ["/usr/lib/pump/apply-settings"]
[config.settings.flow_target]
type = "integer"
minimum = 0
maximum = 120
default = 40
privacy = "public"
[config.settings.plc_password]
type = "string"
privacy = "never-share"
[telemetry]
log_file = "/var/log/pump/pump.log"
[[telemetry.metrics]]
name = "com.acme.tank.level"
unit = "cm"
[diagnostics]
[diagnostics.tests.self-check]
command = ["/usr/lib/pump/self-check"]
[diagnostics.tests.flush-lines]
command = ["/usr/lib/pump/flush-lines"]
disruptive = true
cancellable = true
timeout_sec = 120
[software]
channel = "stable"
sbom_file = "sbom.spdx.json"
update_source = "/var/lib/pump/updates"
update_keys = ["A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"]
install_command = ["/usr/lib/pump/install-update"]
rollback_command = ["/usr/lib/pump/rollback"]
[[software.packages]]
name = "station-fw"
version = "3.2.1"
kind = "firmware"
[artifacts]
max_count = 100
max_total_bytes = 67108864
[schedules]
max = 50
min_interval_sec = 300
[privacy]
consent_required = ["model-provider", "subscriber"]
retention_days = 365
[ai]
providers = ["acme"]
uses = ["explain", "serve"]
monthly_budget_usd = 20.0
[memory]
max_items = 500
[evolving]
suite = "pump-answers"
regression_command = ["/usr/lib/pump/model-regression"]
timeout_sec = 600
[security]
audit_capacity = 5000
secure_boot = true
[platform]
model = "PX-200"
serial = "SN-0042"
power_source = "mains"
clock_source = "ntp"
clock_synced_file = "/run/systemd/timesync/synchronized"
[provisioning]
claim_code_file = "/etc/heartd/claim-code"
local_only = true
[peers]
max = 50
[batch]
max_items = 50
[native]
protocols = ["modbus-tcp"]
targets = ["tcp:127.0.0.1:502:1"]
[[native.protected]]
target = "tcp:127.0.0.1:502:1"
register = 40100
[reasoning_gateway]
ceiling = "internal"
daily_budget_usd = 2.0
[[reasoning_gateway.upstreams]]
name = "main"
provider = "openai"
model = "gpt-4o-mini"
api = "openai"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"
input_usd_per_mtok = 0.15
output_usd_per_mtok = 0.6
[[reasoning_gateway.upstreams]]
name = "backup"
provider = "anthropic"
model = "claude-haiku-4-5"
api = "anthropic"
base_url = "https://api.anthropic.com"
api_key_file = "/etc/heartd/anthropic.key"
input_usd_per_mtok = 1.0
output_usd_per_mtok = 5.0
Host feed#
Write one JSON object per line to host_socket. Each line gets ok, or error with a reason.
{"event": "compressor.started", "severity": "info", "component": "compressor", "summary": "optional"}
{"vital": "errorRate", "value": 0.02}
{"state": "cooling"}
{"phase": "maintenance"}
{"recovered": "compressor"}
{"log": "Compressor started after 3 tries", "severity": "warning", "component": "compressor"}
{"platform": {"power": {"source": "battery", "batteryPct": 64, "lowPower": false}, "network": {"connected": true, "signalDbm": -61}, "clock": {"synced": true, "driftMs": 3}}}
A log line is kept in the telemetry module's log, with secrets removed; without that module it gets error. A platform line updates what GET /platform shows.
Unlike messages from peers, these events can trigger Tending rules, so only the product's own user may open the socket. Names starting circulation., security. or valve. are refused.
Fleet approvals. A fleet command that is not read is approved once at the Gateway, over the fleet digest (the command, the operation, its body, physical, the selector, the sorted target IDs and the rollout). The Gateway then sends each system the operation with a new actionId and a presence of method fleet that it signs for that system. A system with gateway_public_key accepts it as the approval of its own Action only if the signature checks, it is among the targets, the request is exactly the one approved, the approval is at most 300 seconds old, and the approver is not the caller; a physical Action also needs fleet_physical. Its gate, guards and Stop still apply. Every system's request body carries the whole target list, so [server] max_body_bytes bounds the size of a fleet (about 8,000 targets with the default).
Gateway#
heartd gateway <file> reads a different file, shown in examples/gateway/heartd.toml:
| Section | Key | Default | Meaning |
|---|---|---|---|
gateway | id, instance | required | The Gateway's own identity |
state_file | required | Registrations and the Gateway's own state | |
socket_dir | required | Routed sockets must be inside it | |
max_systems | 10000 | ||
rhythm_per_system | 256 | Events kept per pushed system | |
allowed_origins | empty | Browser origins allowed to push | |
allow_private_routes | false | Allow TLS routes to private addresses | |
list_requires_auth | false | Hide the system list from anonymous callers | |
extra_ca | none | Extra certificate to trust for routed systems | |
identity_key_file | identity.key beside the state file | Signs the Gateway's Card, the caller headers it sends, the fleet approvals it gives each system, and the commands it puts on command channels | |
server, auth | As for heartd run |
To route a system over its Unix socket, place the socket inside socket_dir, set trust_gateway_headers = true and gateway_uid on that system, and add an [uplink] section. To route over TLS, set gateway_public_key on the system instead. A system behind NAT sets [uplink] commands = true and gateway_public_key: the Gateway serves it as pushed, and passes any request it cannot answer from what was pushed to the system's command channel, waiting up to 30 seconds for the answer (504 system-unreachable after that, 501 while the system has not polled in the last 65 seconds).
Security rules (Advisory HEART-2026-001)#
- Tokens must carry
audequal to the system ID (the Gateway's ID for the Gateway). A token withcnf.jktmust come with a DPoP proof. Off the loopback address, unbound tokens are refused withtoken-not-bound. A token whoseactclaim names any actor other than a HEART Gateway the issuer marked with"heart_gateway": trueis treated asaiand losesheart.approve; a person working through such a Gateway stayshuman. The Card'slevel: fullneedscontrolinmodules, or heartd does not start. - Approvals carry a passkey assertion over the Action's
digest, inpresencein the decision body. A person's own destructive or physical request carries it in the request body itself, with theactionIdthe client chose, onPOST,PUT,PATCHorDELETE; without it the request waits for another person, because nobody approves their own Action. Passkeys come from[presence]and from enrolment through the API. The first one may be enrolled through the Unix socket or loopback while none is enrolled. - Playbooks added through the API are destructive, and only an administrator can adopt one. A Playbook runs only its own capability, and a physical one only if the rule has
"physical": true. Rules inrulesare treated as adopted by whoever installed the file. - Receipts are salted. The salt goes to the recipient:
receiptSaltto a model,Heart-Receipt-Saltto a Gateway, and theheartreceiptsaltattribute to a webhook. The commitment covers the message without these. - Joining a Gateway at Connected or Full needs a signed Card, sent automatically by
[uplink]. The Gateway pins the key.
Access tokens#
heartd is an OAuth 2.0 resource server. It accepts JWTs signed with EdDSA, from your authorisation server, with these claims:
| Claim | Meaning |
|---|---|
sub | Principal |
iss, exp | Issuer and expiry |
scope | Space-separated scopes, such as heart.read heart.ask |
heart_clearance | public, internal or private |
heart_caller | human, peer, ai or rule. Defaults to peer |
Tending rules#
{
"id": "pump-link-lost",
"enabled": true,
"status": "adopted",
"description": "Raise an alert when the pump controller stops answering",
"when": { "event": "component.failed", "component": "optional", "count": 3, "windowSec": 30 },
"then": { "raise": { "code": "connectivity.dependency-unreachable", "severity": "error", "summary": "Pump controller is not answering" },
"capability": "optional_capability", "arguments": {} }
}
A rule runs only if enabled is true and status is adopted (Core Standard 4.3). Writing adopted in this file is the owner's adoption; a rule with no status is proposed and does nothing. Set adoptedPhysical: true to let it run a physical capability. Use a check such as { "signal": "errorRate", "op": ">", "value": 0.1 } in when to watch a metric; it fires each time a new sample makes the check hold. The older forms ({"vital", "above"}, withinSec, a raise string, physical) are still read. A rule's capability must list rule in callers, and it passes the same gate as any other request.
Running as a service#
packaging/heartd.service is a systemd unit that sets CPU and memory ceilings and runs with few privileges.
Reserve the HEART port so Linux never lends it to an outgoing connection:
echo "net.ipv4.ip_local_reserved_ports = 43278" | sudo tee /etc/sysctl.d/99-heart.conf
sudo sysctl --system