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:

FieldWhat it holds
.jsonthe parsed JSON body (an empty object when the body is not JSON)
.headersheader name (canonical form) to its first value
.bodyTextthe body as text
.attributesprotocol metadata and decisions (ai.decision, …)
.source, .subject, .id, .protocol, .contentType, .receivedAtthe envelope’s fields

Per-message templates go in:

  • destination url, headers and body;
  • the AI router’s state;
  • cache, dedupe and history keys, and history summaries;
  • SSE event names;
  • template transforms.

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, ack and respond;
  • poll sources: url, interval, retry and split;
  • subscribe sources: url, headers and reconnect, plus failover_urls for 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_store progress (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 relative command resolves against the file that declares it. notify rules are concatenated too (they have no names);
  • secrets are 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_urls on a source that does not dial a URL (only ws, wss, sse, grpc, nats, jetstream, mqtt, amqp and redis subscribe sources take it), or retry on a gcs source (its storage client retries within timeout);
  • 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. When GENKIT_REFLECTION_PORT pins 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.readiness and observability.metrics.prometheus in place. A new server.admin.listen binds the new address first, so an address that is taken fails the reload. The old address then answers the requests in flight, including the POST /reload that moved it, and closes. Its /v1/tail streams end. server.admin.disabled starts or stops the admin API the same way. The metrics keep their values.
  • Logs and traces. observability.logs and observability.otel are 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.path hands it over in sequence. The old relay stops its sources, finishes its in-flight messages within server.shutdown_grace and 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 changes buffer.path leaves 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, 1h and 7d.
  • Sizes read like 512, 8KiB and 10MB.
  • Percentages (the fault sink’s fail) read like 50%.
  • Error tags in retry_on and never_on are 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: 429 in retry_on beats 4xx in never_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 }