The spec (hikyaku.yaml)
This page is a guided tour of the spec, the YAML file that declares a relay. Read it when you
write your first hikyaku.yaml, or when you need one section explained. The
spec reference lists every key; it is generated from the Go structs.
A relay is declared in one YAML spec, hikyaku.yaml by default. Keys are snake_case, and the
format is version: 1. Secrets are declared once, resolved by
mamori, and referenced as {{secrets.NAME}}.
The shape
Every top-level section, in outline. The values are placeholders, not a working spec:
# yaml-language-server: $schema=https://xavidop.github.io/hikyaku/schema/hikyaku.schema.json
version: 1 # always 1
name: support-intake # the relay: metrics, logs and shared-state keys use it
secrets: {} # name -> mamori ref
genkit: {} # plugins of the AI router
models: {} # default models, model retries, model fallback chains
defaults: {} # delivery policy every destination inherits
sources: [] # where messages come from
destinations: [] # where they go
stores: [] # shared state: history, decision cache, dedupe, budgets
dead_letter: {} # the last resort (stderr by default)
notify: [] # alerts on dead letters and open circuits
routes: [] # which destinations get the messages of which sources
server: {} # admin API, workers, shutdown
observability: {} # logs, traces, metrics
buffer: {} # the queue between sources and delivery
egress: {} # SSRF guard for templated destination hosts
version, name, sources, destinations and routes are required. Everything else has a
default. A complete spec closes this page.
Secrets
Secrets are the passwords, tokens and keys a relay needs. You declare each one once, under
secrets:, and reference it by name everywhere else.
secrets:
WEBHOOK_SECRET: env:WEBHOOK_SECRET
TYPESAFE_API_KEY: env:TYPESAFE_API_KEY
REDIS_PASSWORD: vault://kv/data/hikyaku#redis_password,env:REDIS_PASSWORD
Each entry is a mamori ref, never a value. A ref is env:NAME, file:///run/secrets/name,
vault://... or one of the other mamori providers. A chain of refs separated by commas is tried
left to right until one has a value. A ref may contain ${VAR}, filled from --var VAR=value on
the command line. Secrets are resolved once, when the spec loads. They are only revealed at the
point of use, and they never reach logs or /debug output.
Reference a secret anywhere a string goes with {{secrets.NAME}}. Using one that is not declared
is a validation error. hikyaku validate checks references without resolving anything.
hikyaku validate --check-secrets resolves every secret and fails on one that has no value.
Security has the details.
Templating
Two kinds of {{ }} live in a spec. One is filled once, when the spec loads. The other is
rendered for each message.
When the spec loads: secrets and environment
{{secrets.NAME}} and {{env.NAME}} are replaced once, when the spec loads. This happens in
every string except the secrets: block itself. {{env.NAME}} reads the process environment
first, then the dotenv file of --env-file:
observability:
otel: { endpoint: "{{env.OTEL_EXPORTER_OTLP_ENDPOINT}}" }
For each message: Go templates
A template that starts with a dot, such as {{ .json.customer_id }}, is a Go template. It is
rendered for each message, and it sees:
| Field | What it holds |
|---|---|
.json | the parsed JSON body (an empty object when the body is not JSON) |
.headers | header name (canonical form) to its first value |
.bodyText | the body as text |
.attributes | protocol metadata and decisions (ai.decision, …) |
.source, .subject, .id, .protocol, .contentType, .receivedAt | the envelope’s fields |
Per-message templates go in:
- destination
url,headersandbody; - the AI router’s
state; - cache, dedupe and history keys, and history summaries;
- SSE event names;
templatetransforms.
A missing key renders empty. The functions are json, header, default, lower, upper,
trim, replace, contains, hasPrefix, join, truncate, sha256, b64enc, b64dec,
now and str.
destinations:
- name: crm
type: https
url: https://crm.internal/customers/{{ .json.customer_id }}/events
headers: { X-Event-Type: "{{ .json.type | default \"unknown\" }}" }
A destination URL whose host is templated is checked against egress.allow.
Sources
A source is where messages come from. The example declares three, one per mode:
sources:
- name: webhooks
type: http
mode: listen
listen: ":8080"
path: /ingest
auth: { hmac: { header: X-Signature-256, algo: sha256, secret: "{{secrets.WEBHOOK_SECRET}}" } }
- name: live-events
type: wss
mode: subscribe
url: wss://stream.example.com/events
headers: { Authorization: "Bearer {{secrets.STREAM_TOKEN}}" }
reconnect: { backoff: 500ms, max_backoff: 30s, jitter: full, max_attempts: 0 } # 0 = forever
failover_urls: [wss://stream-eu.example.com/events]
- name: status-poller
type: https
mode: poll
url: https://api.example.com/status
interval: 30s
retry: { attempts: 3 }
dedupe: { key: "{{ .json.id }}", ttl: 1h }
Every source has a unique name, a type and a mode (each protocol has a default mode). The
other keys depend on the mode:
- listen sources:
listen,path,auth,ackandrespond; - poll sources:
url,interval,retryandsplit; - subscribe sources:
url,headersandreconnect, plusfailover_urlsfor the ws, wss, sse, grpc, nats, jetstream, mqtt, amqp and redis ones.
Validation refuses a key the source’s type ignores. Any source can take dedupe (key, ttl,
store). A message whose key was seen within the ttl (1h by default) is dropped before
routing, acknowledged to its source as filtered, and counted in hikyaku_deduped_total{source}.
With a shared store, the check holds across replicas. Protocols has the
keys of each protocol.
Destinations
A destination is where messages go. The example declares one with a fallback chain, a group of two members, and a best-effort destination:
destinations:
- name: billing
type: https
url: https://billing.internal/api/events
timeout: 3s
deadline: 30s # total time across attempts and fallbacks
fallback: [billing-dr, billing-spool] # tried in order once billing gives up
- name: billing-dr
type: https
url: https://billing-dr.internal/api/events
- name: billing-spool
type: file
path: ./out/billing-spool.ndjson
- name: support
type: group
strategy: failover # failover | round_robin | weighted | least_latency
idempotent: true # the members dedupe: hedging is allowed
health_check: { path: /healthz, interval: 10s, unhealthy_after: 3 }
members:
- { type: https, url: https://support-a.internal/hook }
- { type: https, url: https://support-b.internal/hook }
hedge: { after: 800ms, max: 1 } # race a second member when the first is slow
- name: realtime-dashboard
type: wss
url: wss://dash.internal/ingest
required: false # best-effort: never holds the ack
retry: { attempts: 1 }
A destination is one endpoint or a group of members. fallback, required, idempotent,
transforms and the whole delivery policy are set per destination.
Retries and fallbacks explains each one. A destination with shadow: true
gets a copy of the traffic on the side, see shadow traffic.
Defaults
defaults: is the delivery policy every destination inherits. Set it once instead of on every
destination.
defaults:
timeout: 5s
retry: { attempts: 5, backoff: 200ms, max_backoff: 10s, jitter: full, retry_on: [connect, timeout, 5xx, 429], respect_retry_after: true }
circuit_breaker: { failure_ratio: 0.5, min_requests: 20, open_for: 30s, half_open_max: 5 }
retry_budget: { ratio: 0.2, min_per_second: 10 }
Destinations inherit it field by field: a destination that sets retry: { attempts: 1 }
keeps the default backoff, jitter and retry_on. The values above are the built-in defaults,
so a spec without defaults: behaves the same.
Routes
A route says which destinations get the messages of which sources.
routes:
- name: archive-everything
from: [webhooks, live-events, status-poller]
to: [archive] # the static router, in short
- name: invoices
from: [webhooks]
filter: 'msg.json.type.startsWith("invoice.")'
router:
type: rules
rules:
- { when: 'msg.json.amount > 1000', to: [billing, support] }
- { when: 'true', to: [billing] }
A route takes the messages of its from: sources. It keeps those its filter accepts; the
filter is written in CEL (Common Expression Language), a small language for yes/no conditions.
Then it runs its transforms and asks its router for destinations. Routing
covers the static, rules, sticky and ai routers and the fallback chain between them.
Stores
A store holds the state that replicas share. You need one when you run several replicas. On one replica, the built-in memory store is enough.
stores:
- name: redis-main
type: redis # redis | memory
addr: redis:6379 # or sentinel: {...} / cluster: { addrs: [...] }
username: hikyaku
password: "{{secrets.REDIS_PASSWORD}}"
tls: { enabled: true }
key_prefix: hikyaku
timeout: 50ms # reads must never stall routing
retry: { attempts: 2, backoff: 10ms }
Stores hold:
- routing history;
- the AI decision cache;
- source dedupe keys;
- the retry-budget and AI call-budget counters;
- a source’s
state_storeprogress (Event Hubs checkpoints, the s3 and gcs objects already read).
Other sections reference a store by name. The first one is the default. Without stores:,
hikyaku uses an in-process memory store, which is fine on one replica. See
History-aware routing and Deploy.
Dead letters
The dead-letter queue (DLQ) is the last resort. A message lands here when a required destination and all its fallbacks failed.
dead_letter:
type: file
path: ./out/dlq.ndjson
fallback: [stdout] # if the DLQ itself fails, never drop silently
The DLQ is a destination like any other. It has its own retry (3 attempts by default) and its
own fallback (destination names, or stdout / stderr). Without dead_letter:, dead letters
go to stderr. A file DLQ is what hikyaku dlq replay reads.
notify sends an alert to a destination when a message is dead-lettered.
Genkit and models
Only AI routes need these two sections. genkit: names the model plugins to start. models:
sets the default models, the model retries and the model fallback chains.
genkit:
plugins: [systemone, googlegenai, anthropic, ollama]
systemone:
servers: [typesafe, ollama] # typesafe | openrouter | workersai | ollama
custom:
- provider: corp-decisions
base_url: https://decisions.internal
api_key: "{{secrets.CORP_DECISIONS_KEY}}"
ollama:
url: http://localhost:11434 # the default
openai:
base_url: https://api.openai.com/v1 # the default
models:
default: googleai/gemini-2.5-flash # generative default
decision: typesafe/jev-1.13.0 # decision default; pin a version in production
retry: { attempts: 3, backoff: 500ms, max_backoff: 10s }
fallback:
default: [anthropic/claude-sonnet-4-5, ollama/llama3.2]
decision: [openrouter-decisions/liquid/d1, ollama-decisions/clef]
Plugins named by a model’s provider prefix are added automatically.
genkit.openai.base_url points the openai plugin at any OpenAI-compatible server (vLLM,
LM Studio, LiteLLM, a gateway). Without it, the plugin uses OPENAI_BASE_URL when set, else
OpenAI. Its models are openai/<name>, with any name the server serves. OPENAI_API_KEY goes as
a bearer token; OpenAI needs it, and another server may need none.
Routing: models explains the retry and
fallback chains.
Server, observability and buffer
These sections set up the admin API and the workers, the logs, traces and metrics, the buffer
between sources and delivery, and the egress allow list (the SSRF guard for templated
destination hosts).
server:
admin: { listen: ":9090", token: "{{secrets.ADMIN_TOKEN}}" }
shutdown_grace: 20s
workers: 256
observability:
logs: { level: info, format: json }
otel: { endpoint: "{{env.OTEL_EXPORTER_OTLP_ENDPOINT}}" }
metrics: { prometheus: true }
buffer: { type: memory, size: 10000 }
egress:
allow: ["*.internal", "10.20.0.0/16"]
The admin token guards /reload, /debug/config, /debug/state, /v1/tail, /v1/feedback and
/v1/history* of the admin API. With no token, those answer
loopback clients only. /healthz, /readyz and /metrics are open, and
metrics: { prometheus: false } leaves /metrics out. server.readiness decides what /readyz
checks: see Readiness.
The buffer is in memory by default. With type: disk, it is a bbolt write-ahead log that keeps
queued messages through a crash: see Buffering, ordering and batching.
Multiple files
A spec can be split across several files, for example one file for the base and one for the routes.
hikyaku run -f base.yaml -f routes.yaml
hikyaku run -f ./relay/ # every YAML file in the directory
-f repeats. A directory loads every YAML file in it: the *.yaml and *.yml files directly
inside it, in name order. Files named *.test.yaml or *.test.yml are skipped: they hold the
tests of hikyaku test, not a part of the spec. The files are merged
into one spec:
- lists (
sources,destinations,stores,routes,plugins) are concatenated, and names must stay unique: a duplicate names both files. A plugin’s relativecommandresolves against the file that declares it.notifyrules are concatenated too (they have no names); secretsare unioned;- every other section may be set in one file only;
- every file must agree on
name: one relay per process.
References and fallback cycles are checked on the merged spec. So a fallback in one file can name
a destination of another, and a cycle across files (a → b → a) is reported with its path.
Editor support
Your editor can complete keys and flag mistakes as you type, through the spec’s JSON Schema.
The schema is generated from the Go structs and served with these docs at
/schema/hikyaku.schema.json. Put this line at the top of a spec.
Any editor with the YAML language server (VS Code with the Red Hat YAML extension, Neovim,
JetBrains IDEs) then completes keys, shows their descriptions and flags mistakes as you type:
# yaml-language-server: $schema=https://xavidop.github.io/hikyaku/schema/hikyaku.schema.json
hikyaku schema prints the same schema, and hikyaku schema --out hikyaku.schema.json writes it
to a file. The spec reference is generated from it.
Validation
Validation fails fast, before anything starts. hikyaku validate (and every command that loads a
spec) reports:
- unknown keys and wrong types, with the file and line;
- references to sources, destinations, stores and secrets that are not declared;
- fallback cycles (
a → b → a), across files too; - AI candidates that are not destinations, and hedging on a destination that is not
idempotent(safe to send twice); - protocol and mode combinations the protocol matrix does not have, and keys a type ignores. For
example
failover_urlson a source that does not dial a URL (only ws, wss, sse, grpc, nats, jetstream, mqtt, amqp and redis subscribe sources take it), orretryon a gcs source (its storage client retries withintimeout); - CEL expressions, jq programs and templates that do not compile;
- with
--check-secrets, secrets that do not resolve.
Some protocol keys are checked by the protocol’s own module when the relay builds the source or
destination: hikyaku run builds both, and hikyaku doctor builds the destinations.
It exits 2 with every error. hikyaku lint goes further. It warns about specs that work but
probably should not:
- unpinned decision models;
- AI routers without a router fallback;
- required destinations without a fallback;
- listen sources without
auth; - no admin token.
hikyaku lint --strict fails on warnings, for CI.
Hot reload
A running relay can pick up a new spec without a restart. It reloads on SIGHUP, on
POST /reload to the admin API, or by itself with hikyaku run --watch. The new graph is
validated and built next to the running one, then swapped in. A spec that does not validate or
build is reported, and the running one stays. hikyaku_reloads_total{outcome} counts reloads.
Some parts of the spec need more than a swap:
- Genkit and model providers. A reload may change
genkit:, the model providers, or the value of a secret (a provider’s API key may be one). Then it starts a new Genkit instance with the new plugins, next to the running one. The new engine routes with it, and the old instance stops with the old engine. A provider that does not start, such as one without its API key, fails the reload, and the running relay stays. A reload that changes none of them keeps the running instance. - The Genkit Developer UI. Under
hikyaku dev, the new Genkit instance starts a reflection server of its own, and the Developer UI moves to it. WhenGENKIT_REFLECTION_PORTpins a port, both instances would need it, so the reload is refused (needs_restart). Restart hikyaku, or leave the variable unset. - The admin API. A reload applies
server.admin.token,server.readinessandobservability.metrics.prometheusin place. A newserver.admin.listenbinds the new address first, so an address that is taken fails the reload. The old address then answers the requests in flight, including thePOST /reloadthat moved it, and closes. Its/v1/tailstreams end.server.admin.disabledstarts or stops the admin API the same way. The metrics keep their values. - Logs and traces.
observability.logsandobservability.otelare set up at start. A change to them applies after a restart. - A disk buffer. Only one relay can hold the buffer file. So a reload that keeps the same
buffer.pathhands it over in sequence. The old relay stops its sources, finishes its in-flight messages withinserver.shutdown_graceand closes the buffer. Then the new one opens it and delivers what is left. Listen sources refuse connections meanwhile. If the new spec does not start, the old one starts again. A reload that changesbuffer.pathleaves the old file with its messages, until a relay opens it again.
Durations, sizes and error tags
These value formats appear throughout the spec.
- Durations read like
500ms,30s,5m,1hand7d. - Sizes read like
512,8KiBand10MB. - Percentages (the fault sink’s
fail) read like50%. - Error tags in
retry_onandnever_onare classes (connect,timeout), status ranges (5xx,4xx), exact codes (503,429; a bare number works) and protocol codes (ws:close_1011). The more specific tag wins:429inretry_onbeats4xxinnever_on. A failure whose tags match neither list is retried unless its class is permanent. See Error tags.
A complete spec
Every section together. It validates as is; set the secrets it declares to run it.
# hikyaku.yaml
# yaml-language-server: $schema=https://xavidop.github.io/hikyaku/schema/hikyaku.schema.json
version: 1
name: support-intake
secrets:
WEBHOOK_SECRET: env:WEBHOOK_SECRET
TYPESAFE_API_KEY: env:TYPESAFE_API_KEY
GEMINI_API_KEY: env:GEMINI_API_KEY
ANTHROPIC_API_KEY: env:ANTHROPIC_API_KEY
STREAM_TOKEN: env:STREAM_TOKEN
CORP_DECISIONS_KEY: env:CORP_DECISIONS_KEY
REDIS_PASSWORD: env:REDIS_PASSWORD
ADMIN_TOKEN: env:ADMIN_TOKEN
genkit:
plugins: [systemone, googlegenai, anthropic, ollama]
systemone:
servers: [typesafe, ollama]
custom:
- provider: corp-decisions
base_url: https://decisions.internal
api_key: "{{secrets.CORP_DECISIONS_KEY}}"
models:
default: googleai/gemini-2.5-flash
decision: typesafe/jev-1.13.0
retry: { attempts: 3, backoff: 500ms, max_backoff: 10s }
fallback:
default: [anthropic/claude-sonnet-4-5, ollama/llama3.2]
decision: [openrouter-decisions/liquid/d1, ollama-decisions/clef]
defaults:
timeout: 5s
retry: { attempts: 5, backoff: 200ms, max_backoff: 10s, jitter: full, retry_on: [connect, timeout, 5xx, 429], respect_retry_after: true }
circuit_breaker: { failure_ratio: 0.5, min_requests: 20, open_for: 30s, half_open_max: 5 }
retry_budget: { ratio: 0.2, min_per_second: 10 }
sources:
- name: webhooks
type: http
mode: listen
listen: ":8080"
path: /ingest
auth: { hmac: { header: X-Signature-256, algo: sha256, secret: "{{secrets.WEBHOOK_SECRET}}" } }
- name: live-events
type: wss
mode: subscribe
url: wss://stream.example.com/events
headers: { Authorization: "Bearer {{secrets.STREAM_TOKEN}}" }
reconnect: { backoff: 500ms, max_backoff: 30s, jitter: full, max_attempts: 0 }
failover_urls: [wss://stream-eu.example.com/events]
- name: status-poller
type: https
mode: poll
url: https://api.example.com/status
interval: 30s
retry: { attempts: 3 }
dedupe: { key: "{{ .json.id }}", ttl: 1h }
destinations:
- name: billing
type: https
url: https://billing.internal/api/events
method: POST
timeout: 3s
deadline: 30s
fallback: [billing-dr, billing-spool]
- name: billing-dr
type: https
url: https://billing-dr.internal/api/events
- name: billing-spool
type: file
path: ./out/billing-spool.ndjson
- name: support
type: group
strategy: failover
idempotent: true
health_check: { path: /healthz, interval: 10s, unhealthy_after: 3 }
members:
- { type: https, url: https://support-a.internal/hook }
- { type: https, url: https://support-b.internal/hook }
hedge: { after: 800ms, max: 1 }
- name: realtime-dashboard
type: wss
url: wss://dash.internal/ingest
required: false
retry: { attempts: 1 }
- name: archive
type: file
path: ./out/archive.ndjson
stores:
- name: redis-main
type: redis
addr: redis:6379
username: hikyaku
password: "{{secrets.REDIS_PASSWORD}}"
tls: { enabled: true }
db: 0
key_prefix: hikyaku
timeout: 50ms
retry: { attempts: 2, backoff: 10ms }
dead_letter:
type: file
path: ./out/dlq.ndjson
fallback: [stdout]
routes:
- name: archive-everything
from: [webhooks, live-events, status-poller]
to: [archive]
- name: smart-triage
from: [webhooks, live-events]
router:
type: ai
engine: decision
model: typesafe/jev-1.13.0
fallback_models: [ollama-decisions/clef]
instructions: |
You route customer events for a SaaS company. Pick the team(s) that must see it.
state: "{{ .bodyText }}"
state_json: true
candidates:
- { name: billing, description: "Payments, invoices, refunds, subscription changes" }
- { name: support, description: "Bugs, outages, how-to questions, angry customers" }
- { name: realtime-dashboard, description: "Anything on-call should see live" }
multi: true
min_confidence: 0.6
input: { max_bytes: 8KiB, redact: ["$.card.number", "$.email"] }
timeout: 4s
cache: { store: redis-main, key: "{{ .json.ticket_id }}", ttl: 10m }
history:
store: redis-main
key: "{{ .json.customer_id }}"
window: { max_entries: 10, max_age: 24h }
ttl: 7d
record: { state: redacted }
sticky: { enabled: true, follow_up_threshold: 0.8 }
on_store_error: ignore
on_low_confidence: [support]
fallback:
- type: rules
rules:
- { when: 'msg.json.type.startsWith("invoice.")', to: [billing] }
- type: static
to: [support]
server:
admin: { listen: ":9090", token: "{{secrets.ADMIN_TOKEN}}" }
shutdown_grace: 20s
observability:
logs: { level: info, format: json }
otel: { endpoint: "{{env.OTEL_EXPORTER_OTLP_ENDPOINT}}" }
metrics: { prometheus: true }
buffer: { type: memory, size: 10000 }