Vejas
Vejas is an open-source integration platform with no builder UI: agents write the integration code, humans own what it means. Flows are written in VejasScript — a small, pure, per-event language whose business surface (thresholds, transcoding tables, rules) is extracted from the code itself and edited by domain experts in a panel, without touching the code.
Three design commitments shape everything:
- One infrastructure. NATS/JetStream is the only dependency: transport,
persistence, KV, locks, audit — the bus is the platform’s memory and its
scaling substrate. Two containers, one
docker compose up(ADR-0002). - Agent-native. The runtime is an MCP server. An agent connects,
reads the language contract (
vejas_language), writes a flow, tests it against a fixture, and it lands running — governance optional but first-class: in governed mode agents can only propose; a human approves (ADR-0006, ADR-0024). - Measured, not claimed. Every number in these docs comes from a
reproducible benchmark in
bench/: cold start 11 ms, 6–8 MB RSS under load, end-to-end p50 2 ms uncongested, every hop persisted, cluster promote in 60 ms — lossless.
Where to go
- New here → Install & first run, then your first flow.
- Building an API → Expose an API — sync and async.
- Running in production → Clustering, DLQ & replay, changing safely, governed mode.
- Connecting systems → the certified catalog.
- The full “why” → the ADRs: 27 recorded decisions.
Install & first run
Docker (recommended)
git clone https://github.com/cpoder/vejas && cd vejas
docker compose up # nats + vejas — nothing else
The panel is at http://localhost:8686. The webhook entry listens on
:8787 — POST /ingest/<subject> publishes the JSON body on the bus.
From source
cargo build --release --manifest-path core/Cargo.toml
nats-server -js & # the only dependency
VEJAS_ROOT=./my-root core/target/release/vejas-runtime
VEJAS_ROOT is a plain directory: flows/, connectors/, tables/,
tests/. Everything is a file; git is the source of truth.
Connect an agent
claude mcp add --transport http vejas http://localhost:8686/mcp
Any MCP client works — the runtime is the MCP server, no separate process.
Ask the agent for a flow in plain words; it reads vejas_language over MCP,
writes the .vjs, tests it against a fixture, and the supervisor picks it
up.
Security defaults
The panel/MCP port (8686) binds to localhost by default — that surface
writes flows and can run commands (exec connectors), so expose it
deliberately. Set VEJAS_TOKEN and every write (/mcp included) requires
Authorization: Bearer. The webhook port (8787) only publishes events.
For a “no agent lands anything alone” posture, see
governed mode.
Your first flow
Say it to your agent:
Watch helpdesk tickets on
vx.helpdesk.tickets. Priority arrives in French — map critique→P1, haute→P2, otherwise P3 — and post P1/P2 alerts to Slack with the requester’s email, lowercased.
What lands (flows/helpdesk_ticket_alerts.vjs):
# flow: helpdesk_ticket_alerts
source "vx.helpdesk.tickets"
# French priority label -> severity code. The business expert edits this table.
SEVERITY_CODES = {"critique": "P1", "haute": "P2"}
ALERT_LEVELS = ["P1", "P2"]
code = SEVERITY_CODES[priority] ?? "P3"
email = lower(requester?.email)
if code in ALERT_LEVELS:
emit "vx.slack.out", {text: f"[{code}] {subject} — {email}"}
end
Three things to notice:
- The business lives in UPPERCASE literals.
SEVERITY_CODESandALERT_LEVELSappear in the panel as editable tables — a domain expert corrects"haute"to"P1"without reading a line of code, and the flow restarts itself (ADR-0005). Adding a new row —"bloquante" → "P1"— is the same gesture: the panel inserts it span-exact, no agent needed (changing the flow’s logic is the one that’s a sentence to the agent). - The language is pure. No I/O, no clock, no network in a flow — input event in, emits out. That is what makes replay, time-travel and canary structurally safe later.
- Delivery is at-least-once, persisted. The event was on JetStream before the flow saw it; the emit is acknowledged before the input is.
Feed it an event:
curl -X POST localhost:8787/ingest/helpdesk.tickets \
-d '{"priority":"critique","subject":"SAP down","requester":{"email":"[email protected]"}}'
then watch it in the panel — the event, its emit, and the editable surface, side by side. More recipes, each validated live: the cookbook.
The panel
One embedded HTML page at http://localhost:8686 — no build step, no SPA
stack. It is the human side of the platform: everything an expert or an
operator needs to read the system and own the meaning, nothing that
turns them into a flow developer.
What you can do there
- See the pipeline. The diagram is a filter: click a node and the event
cards, flows and connectors narrow to that path; deep-link with
#focus=f:<flow>(multi-selection supported). - Edit the business surface. Every UPPERCASE literal — thresholds,
transcoding tables, queue names — is editable in place. Saving goes
through the same audited write path agents use (
/surface/set), and the unit restarts itself. Credential-shaped values are masked (single-sourced pattern, ADR-0017). - Read the rules. The rules view projects each flow’s decision logic as faithful sentences — whole-sentence or verbatim code, never a half translation (the rules view).
- Watch events. A live ring of traced events with payloads; any event can be replayed against a candidate version (the replay strip), or starred ★ into a curated golden test (golden traffic).
- Approve. In governed mode the approval queue lists agent proposals with their evidence next to the Approve button (governed mode).
- Dead letters. The DLQ card shows death envelopes (version-tagged) and offers explicit replay (DLQ & replay).
The bus
NATS with JetStream is the only infrastructure (ADR-0002). Not “the message broker among other services” — the entire platform substrate:
- Transport & persistence. Every event between units crosses a
JetStream stream (
VEJAS, subjectsvx.>). A hop is acknowledged only after the next stage’s write is confirmed — publish-before-ack, so a crash anywhere redelivers instead of losing (at-least-once, end to end). - State. Offsets, versions, leases, proposals: JetStream KV buckets. There is no database.
- Scaling. Flows and sinks are competing consumers on durables — add an instance, throughput distributes; kill -9 one, nothing is lost (measured: 20 000/20 000 through an instance kill under load). Sources that must be singletons take a KV lease (clustering).
- Audit. Mutations append to an audit stream; the DLQ is a subject
hierarchy (
vxdlq.>) with death envelopes.
Subjects
Everything speaks vx.<domain>.<name> (root configurable via
VEJAS_SUBJECT_ROOT). A connector publishes into the taxonomy or
consumes from it; a flow declares source "vx…" and emits to other
subjects. An external connector in any language is a first-class
citizen by following the same rules on the same bus — publish JSON on
vx.<domain>.<name>, consume with a durable, ack after your side effect.
The full contract: Subjects reference.
Delivery contract
At-least-once, per-subject FIFO, redelivery after VEJAS_ACK_WAIT_SECS
(default 30s; floor 1s), 5 attempts then the dead-letter queue with an
explicit envelope — never a silent drop
(DLQ & replay). Consumers must be idempotent or
carry a dedup key; the transport invariants are CI-tested on a live bus
(FIFO, redelivery→cap→DLQ, kill -9 no-loss, reconnection).
Flows & VejasScript
A flow is one file under flows/: a pure, per-event program. One
trigger (source subject, api route, or MCP tool), transformations,
emits and/or a respond. No I/O, no clock, no network — the language
cannot express them. That purity is load-bearing:
- the business surface (UPPERCASE literals) can be extracted, shown and edited safely — a change is a literal swap plus a unit restart;
- any persisted event can be replayed through any version of the flow with zero side effects — which is what makes time-travel and canary structural rather than bolted on;
- a fixture plus
vejas_run_flowis a complete test.
The language fits in 20 lines — see the
VejasScript reference. Highlights: null-safe
access (requester?.email), array projection/filtering
(orders[total > 100]), f-strings, transcoding via literal dicts,
invoke to compose services (services/<name>.vjs, cross-package with
EXPORTS), secret("path/key") for credentials — never a literal.
Lifecycle
The supervisor watches VEJAS_ROOT: a new or changed file becomes a
running unit; a broken one fails loudly without taking the rest down. Each
flow gets a durable consumer on its source subject, so stopped is not
losing: events accumulate on the stream and drain on restart. Tests live
next to the code (tests/vjs/ golden cases, per-flow fixtures) and run in
CI with vejas-runtime vjs-test.
Composing services
A flow does one job end to end. When several flows need the same piece of
logic — format an alert, classify a ticket, shape a payload — you factor it
into a service and invoke it. Same language, same purity, no
duplication.
A service is a flow without a trigger
A service lives in services/<name>.vjs (or packages/<pkg>/services/). It
looks like a flow but declares no source, api, or tool — it never runs
on its own. It receives named arguments, and produces variables. Real
example (services/format_alert.vjs):
# service: format_alert — Inputs: sev, subj, email. Output: alert_text.
alert_text = f"[{sev}] {subj} - {lower(email)}"
Because a service is pure VejasScript, everything true of a flow holds: its literals are part of the business surface, it is statically analyzable, and it has no I/O of its own.
invoke — merge, or capture
A flow calls a service with named arguments, two ways:
# merge: the service's outputs land in THIS flow's variables
invoke format_alert(sev: code, subj: subject, email: requester.email)
emit "vx.slack.out", {text: alert_text} # alert_text came from the service
# capture: take the service's whole pipeline as one document
d = invoke format_alert(sev: code, subj: subject, email: requester.email)
emit "vx.slack.out", {text: d.alert_text}
Merge is the common case — the service extends the caller’s pipeline. Capture is for when you want the result as a self-contained document (to nest, compare, or pass on).
Across packages: EXPORTS
Packages group flows and services. A service
is package-private by default. To call one from another package, the
owning package must export it in its package.vjs:
# packages/notifications/package.vjs
ENABLED = true
EXPORTS = ["notify_slack"] # callable from other packages; the rest stays private
Then, from another package:
invoke notifications:notify_slack(channel: "#ops", text: alert_text)
When to compose, and when to use the bus
invokewhen it’s synchronous shared logic inside one processing step — formatting, classification, a lookup shape. It runs in the caller’s pipeline, same event, no persistence boundary.- The bus (
emitto a subject another flowsources) when it’s a separate stage that deserves its own delivery guarantee, retry/replay, or independent scaling — the persisted, at-least-once path. Between packages, prefer the bus;EXPORTSis the deliberate exception for genuinely shared synchronous helpers.
A rule of thumb: if you’d want the step to survive a crash and replay on its
own, it’s a bus hop, not an invoke.
Connectors
A connector puts external systems on the bus. There are three shapes, by escalating need (ADR-0007/0011):
- A manifest on a built-in driver — most SaaS is this: a
.vjsfile withdriver "http-poll"(oroauth-poll,http-out,http-in,mqtt-in/out,timer,slack-out…) and UPPERCASE config. No code. The driver catalog with each config contract:GET /driversor thevejas_driversMCP tool. - An exec bridge —
exec-source/exec-sink/exec-stream-source/rpc:execrun a child program in any language over stdio. Kafka rides this (kcat carries the full librdkafka auth matrix); the generic offset-resume (OFFSET_KV) gives publish-before-commit resume in our KV, kill -9-proof (CI-tested). - A first-class standalone binary — when the ordering that guarantees no-loss must live in one process with its own bus client: IBM MQ (MQGET-under-syncpoint → bus pub-ack → MQCMIT) and AMQP/RabbitMQ (consume→pub-ack→ack; publish→confirm→ack). Configured by env, not by manifest. See Brokers.
Whatever the shape, the same rules hold: subjects under vx., at-least-once
with the ack after the side effect, credentials through secret() or the
deployment’s secret machinery — never a literal (a CI lint enforces it on
every certified recipe), and an admission test that proves data actually
flows (the certified catalog).
The business surface
The core bet of the platform (ADR-0005): the agent owns how, the human owns what it means. In practice, “what it means” lives in the flow’s UPPERCASE literals — thresholds, transcoding tables, routing keys, feature lists. The runtime extracts them from the AST with their exact source spans and serves them as the surface:
- the panel shows them as editable values and tables next to live sample events;
- editing writes back literally (span-exact, no reformatting), audits the change, and restarts just that unit;
- credential-shaped keys are masked and must be
secret()references — the same single-sourced pattern gates CI, the panel, and generation.
Three levels of “editable” (ADR-0019):
- N1 — parameters: the literals above; already editable.
- N2 — the rules view: the flow’s decision logic read as faithful sentences (guide) with its inline literals editable; the structure itself is read-only.
- N3 — structure: changing the logic is an agent conversation — never a form-based rules editor. That line is deliberate: half-editable code is how platforms rot.
Secrets
The rule is absolute (ADR-0008): a credential never appears as a literal
— not in a flow, not in a manifest, not in the panel, not in git diff.
secret("path/key") resolves at run time, fail-closed, from:
VEJAS_SECRET_<PATH>environment entries (containers, CI);- a file store (
VEJAS_SECRETS_FILE) for dev; - HashiCorp Vault (
VAULT_ADDR/VAULT_TOKEN/VEJAS_VAULT_MOUNT).
What keeps the rule real, rather than aspirational:
- One pattern, three enforcement points. The credential-shaped-key
pattern is a single constant in the runtime (
vejas-runtime secret-patternprints it); the panel masks with it, CI lints every certified recipe with it (env-file recipes included), and the agent generation contract embeds it. The pattern itself was chosen on a 55-key labeled benchmark, and a CI test pins the exact profile — changing it reopens the decision with data, never silently. - Standalone binaries follow the deployment’s own machinery (env from a
secret store, CCDT/TLS keystores) — their recipes are linted the same:
a credential-shaped env key must be a
${VAR:?}reference, never a value. - Secret paths are listed (
GET /secrets); values are never returned.
Agents & MCP
There is no builder UI to click together — the flow-writing interface is
an agent, and the runtime is the MCP server (POST /mcp, ADR-0006). No
sidecar, no plugin: the platform’s own write path, test path and telemetry
are the 29 MCP tools.
The loop an agent follows (enforced by the tool descriptions themselves — the generation contract):
vejas_language— read the language and its rules first.vejas_drivers/vejas_surface/vejas_topology— see what exists.- Write:
vejas_write_flow/vejas_new_connector— literals for anything a human may want to change,secret()for anything confidential. - Prove: fixture +
vejas_run_flow(pure, no side effects), probe connectors withvejas_test_connector. - Ship — or in governed mode, propose:
vejas_proposecarries the change plus the evidence (time-travel result, canary stats), and a human approves in the panel (governed mode).
Flows themselves can face agents: tool "description" exposes a flow as an
MCP tool — the platform grows its own tool surface as flows are written.
And the self-healing loop closes the circle: a dead letter → the agent
reads vejas_dlq, drafts a candidate, proves it on real traffic
(vejas_time_travel, vejas_canary_start), proposes with evidence → human
approves → cluster-wide promote in 60 ms → vejas_dlq_replay.
Expose an API — sync and async
Vejas gives you both interaction semantics, chosen per endpoint — not a global mode:
| Async ingestion | Sync API flow | |
|---|---|---|
| Entry | http-in connector: POST :8787/ingest/<subject> | api "VERB /path" in the flow: /api/... on the panel port |
| Caller gets | 202 after the JetStream pub-ack | The flow’s respond <status>, {body} |
| Durability | Persisted before processing; at-least-once through the whole pipeline | Computed in the request; a crash mid-request is the caller’s retry (plain HTTP semantics) |
| Backpressure | The stream absorbs bursts; consumers drain at their pace | The caller waits |
| Use for | Webhooks, events, anything fire-and-forget | Lookups, validations, request/response REST |
Async: a webhook that cannot lose
# connector: orders_webhook
driver "http-in"
PORT = 8787
POST /ingest/shop.orders publishes the JSON body on vx.shop.orders and
answers 202 only after JetStream confirmed the write — the caller’s
success means persisted, not processed. Flows consume from there with
the full delivery contract (redelivery, DLQ).
Lock the port to its subjects. The /ingest port is unauthenticated by
design — it only publishes. Without a limit, any caller can publish to any
vx.* subject, a sink’s subject included, triggering an outbound side
effect with no flow in between. Set ALLOW to the subject suffixes this
webhook is for:
driver "http-in"
PORT = 8787
ALLOW = ["shop"] # shop, shop.orders, shop.refunds … — 403 for anything else
Match is by subject segment: "shop" allows shop and shop.orders, not
shop_internal. Absent, the port stays open to any vx.* (backward
compatible) — set it whenever the port is reachable by anyone but the
operator (ADR-0029). It is defence in depth, not authentication: put the
port behind your ingress trust boundary as you would any webhook.
Sync: a flow that is an API
# flow: order_status
api "GET /orders/{id}"
API_RESPONSE = {id: "string", status: "string"}
respond 200, {id: id, status: "shipped"}
- One flow per verb: a REST resource is several small flows.
{path params}, the JSON body andqueryall arrive as event variables.respondis the HTTP answer;emitstill fires bus side-effects (best-effort — if the side effect must be guaranteed, emit to a subject and let an async flow own it).- The whole API self-describes at
GET /api/openapi.json(API_REQUEST/API_RESPONSEliterals type it;VEJAS_API_TITLEand friends fill the metadata).
Mixing them
A common shape: POST /api/orders (sync) validates and answers 201 with
an id, and emits vx.orders.accepted — everything downstream (ERP sync,
notifications) rides the async pipeline with its delivery guarantees. You
choose the boundary per flow, and can move it later without changing
infrastructure.
Handle failures: DLQ & replay
The delivery contract ends in one of two places: the message is processed, or it is parked with a verdict — never silently dropped (ADR-0015).
How a message dies
A flow error → redelivery (after VEJAS_ACK_WAIT_SECS) → up to 5 attempts
→ the dead-letter queue. Unparseable input (bad JSON) is direct poison —
no pointless retries. The dead letter lands on vxdlq.<original subject>
wrapped in a death envelope: original payload, error, attempt count,
timestamps, and the flow version that killed it (ADR-0021) — so a
post-mortem knows which logic failed, even after a promote.
Seeing and replaying
- Panel: the DLQ card; API:
GET /dlq; agent:vejas_dlq. - Replay is explicit —
POST /dlq/replay(vejas_dlq_replay), never automatic: if the logic was wrong, replaying before fixing just kills the message again. Purge (/dlq/purge) is equally explicit and audited.
The loop that makes it self-healing
- A message dies; the envelope carries version
v3. - An agent reads
vejas_dlq, drafts a candidate flow, proves it: replay yesterday’s real traffic through it (time-travel), watch it shadow live traffic (canary). - In governed mode it proposes with that evidence; you approve — the promote fans out cluster-wide in 60 ms.
vejas_dlq_replay— the dead messages pass under the new version, and their envelopes record the transition.
The human owns exactly one step: the meaning.
Change safely: versions, time-travel, canary
The business surface is meant to be edited live — a threshold, a mapping table, a routing rule (see the rules view). The point of this guide is the safety around that edit: you never change meaning blind. You preview the change against real traffic, promote it atomically, and can roll it back — all without a redeploy (ADR-0005, ADR-0021).
Preview before you promote
Shadow-replay reruns the flow’s last real events through the proposed change and shows you the before/after diff — nothing is published, nothing is committed.
- Panel: edit a literal → Apply → the shadow strip shows what would change → Promote or Discard.
- API:
POST /surface/replay; agent:vejas_replay_literal.
If there is no recent traffic to replay against, Apply promotes directly — there is nothing to preview.
Promote and roll back
A promote rewrites one literal in place, hot-reloads the flow, and records an audit entry — no process restart.
- Promote:
POST /surface/set(vejas_set_literal). - Roll back:
POST /surface/rollback(vejas_rollback_literal) — itself a forward-only, audited promote back to the value the literal held before its last change. History is never rewritten.
Bigger changes: time-travel and canary
For a change that touches a whole version of a flow (not one literal), two tools let you judge it against reality before it goes live:
| Time-travel | Canary | |
|---|---|---|
| Against | A window of past persisted traffic | Live traffic, as it arrives |
| What it does | Replays that window through the candidate version, diffs vs. current | Shadow-follows the flow, diffs each live event |
| Read | vejas_time_travel / POST /surface/timetravel | vejas_canary_status, GET /surface/canary |
| Start/stop | one-shot | vejas_canary_start / _stop |
Both obey the shadow invariant: the candidate runs in a shadow engine and its emits are never published. You are comparing outcomes, not double-sending them.
In a cluster, a promote is a version
With VEJAS_CLUSTER=1, a local file write is refused — a promote instead
publishes a version into a shared JetStream KV overlay that every
instance converges on (benchmarks: 60 ms
convergence, lossless mid-burst). If a later git deploy moves the baseline
the promote was made against, the overlay is evicted loudly (git wins;
the promoted content is kept in version history to re-promote) — visible at
GET /evictions. You never get a half-cluster running two meanings.
The whole loop
Fix a dead letter: read the failure, draft the change, prove it (time-travel yesterday’s traffic, canary today’s), promote — and in governed mode that proof is the evidence a human approves. The human owns one decision: the meaning.
Governed mode: proposals & approvals
An agent is good at how — reading a dead letter, drafting the fix, proving it against real traffic. It should not be the one who decides what a rule means in production. Governed mode draws that line in the runtime: agents propose, a human approves (ADR-0024).
Turn it on
VEJAS_REQUIRE_APPROVAL=1
VEJAS_APPROVAL_TOKEN=<a secret distinct from VEJAS_TOKEN>
The approval token must differ from the agent’s VEJAS_TOKEN — the
agent holds VEJAS_TOKEN to reach /mcp, so a shared secret would let it
approve its own change. The runtime refuses to start if
VEJAS_REQUIRE_APPROVAL is set without a distinct VEJAS_APPROVAL_TOKEN: a
governance mode with a shared key is governance in name only.
What changes
With it on, every mutation door — the mutating MCP tools and the raw
HTTP endpoints (/surface/set, /flows/new, /secrets/set, …) — stops
executing and answers:
409 approval required: submit a proposal (vejas_propose, or the panel)
Reads stay open. A direct write is never silently accepted; it is turned into a request.
The flow
- Agent proposes —
vejas_proposewith the change and its evidence (a shadow-replay diff, a canary result). It cannot approve.vejas_proposalslists the queue. - Human decides — in the panel’s Approval queue card, or
POST /proposals/{id}/approve|rejectwith theX-Approval-Tokenheader. The card shows the evidence; a proposal with none is flagged “⚠ No evidence” loud — approving blind is a deliberate act. - On approve — the change executes exactly as a normal promote would (hot-reload, or a cluster-wide version in 60 ms) and is recorded.
Two safeguards
- Audit outlives the queue. The live queue is a bounded JetStream KV
(
VEJAS_PROPOSALS); the durable proof of who approved what is a separate audit stream (VEJAS_AUDIT) — a proposal aging out of the queue never takes its approval record with it. - No stale approvals. The baseline is re-checked at approve time: if the surface moved since the proposal was made, it auto-expires — re-propose against the current state.
Where it fits
Governed mode is the seam of the whole change-safely loop: the agent does the work and the proving, the human owns the meaning, and every step is on the record. Leave it off for a single-operator dev box; turn it on where a wrong meaning is a real incident.
Cluster & zero-downtime
Run N instances of the runtime against the same bus. There is no coordinator to install and no quorum to configure — the bus is the coordination (ADR-0020). Turn it on with one variable:
VEJAS_CLUSTER=1
What scales, and what stays single
| Unit | In a cluster | Why |
|---|---|---|
| Flows, sinks | All N run — a shared JetStream durable load-balances | Competing consumers: each message goes to exactly one instance |
http-in | All N run behind your load balancer | Each has its own listener; ingestion is stateless |
Singleton sources (interval, poll, exec, stream, mqtt) | Exactly one runs | N timers/getters would produce N× the events |
A singleton source takes a lease in a JetStream KV bucket
(VEJAS_LEASES) before it runs:
- acquire = atomic create-if-absent — exactly one instance wins.
- renew = compare-and-set — a paused leader that wakes with a stale revision stands down (fencing: two instances never keep running the same unit).
- release = delete on graceful shutdown — instant handoff.
- failover = the bucket’s TTL (
VEJAS_LEASE_TTL_SECS, default 10 s) ages a crashed leader’s lease out; a stand-by acquires.
Rolling a deploy with no loss
Because every publish is confirmed by JetStream before the source acks its input, a killed instance loses nothing — the message redelivers to a survivor.
- Instance
kill -9under load: 20 000/20 000, zero loss. - Singleton failover: ~2.6 s graceful, ~5.9 s crash (TTL-bound).
(benchmarks.) Graceful shutdown rides
SIGTERM (a k8s rolling restart): the lease hands off, in-flight work
drains, the instance exits.
Changing meaning across the cluster
In cluster mode a local file write is refused (409) — a split where
one instance fixed a rule and the others did not is the worst failure for a
business surface. Change flows through GitOps, or through a version that
publishes cluster-wide and every instance converges on in 60 ms,
lossless (change safely).
Scaling past one getter
A singleton source is single by correctness, not capacity. When one getter is a bottleneck, partition: one manifest per key range (e.g. one Kafka consumer per partition set, each its own offset key). Ordered by default; throughput is a choice you make on purpose.
The rules view
The reason Vejas keeps flows as one screen of readable VejasScript is so the meaning — the thresholds, the mappings, the routing conditions — is visible and correctable by the person who owns it, not buried in code (ADR-0019). The rules view is where a domain expert reads and fixes that meaning without touching logic.
The business surface
Every flow’s editable meaning — its constants, transcoding tables, and
mappings — is the business surface. It self-describes at GET /surface
(vejas_surface):
{ "name": "SEVERITY_CODES", "kind": "table",
"value": { "critique": "P1", "haute": "P2", "normale": "P3", "basse": "P4" } }
The panel renders each entry as an editable card; the agent reads the same surface to know what it may safely change.
Rules as sentences
Beyond the raw literals, the rules view projects a flow’s decision
branches into plain conditions — GET /rules?file=<flow>:
{ "rules": [
{ "kind": "if", "when": "severity in ALERT_SEVERITIES",
"then": ["→ vx.slack.out"], "literals": ["ALERT_SEVERITIES"],
"projectable": true } ]}
You read “when severity is in ALERT_SEVERITIES, alert Slack” — and the
literals tell you exactly which value to edit to change who gets alerted.
A branch that is too dynamic to project cleanly is marked
projectable: false and shown as its raw source rather than a misleading
sentence.
Correcting a value
Editing one entry is a promote:
- Panel: change the card → Apply.
- API:
POST /surface/set; agent:vejas_set_literal.
It hot-reloads that flow — the one unit picks up the new value, no process restart and no deploy. And you don’t do it blind: Apply previews the change against real traffic first (shadow-replay), so you see what would have differed before you commit. That whole safety story — preview, promote, roll back, canary — is change safely.
Why this is the differentiator
Anyone can generate glue that moves data. The thing an integration platform rarely gives you is a place where the person who knows the business can correct what a rule means — live, previewed, and audited — while the person who knows the plumbing keeps owning the plumbing. The rules view is that place.
Observability
Three windows into a running runtime, all built in — no sidecar, no agent to install (ADR-0016): a metrics endpoint to scrape, traces to a collector, and a live event ring to see what each flow just did.
Metrics — Prometheus
GET /metrics is always on, plain Prometheus exposition:
vejas_up 1
vejas_units{kind="flow"} 4
vejas_units{kind="connector"} 3
vejas_flow_restarts{unit="order_sync"} 0
Gauges (up, supervised units by kind, restarts per unit) come live from the supervision registry; the flow hot path adds counters and latency histograms. Point your Prometheus at the panel port and you have unit health, throughput, and error rate with no configuration.
Traces — OTLP
Set one variable and the runtime exports spans to any OpenTelemetry collector:
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
OTEL_SERVICE_NAME=vejas # optional label
Unset, it is a genuine no-op — no exporter thread, no overhead. Each flow execution becomes a span (subject, ok/error, emit count, timing), so an end-to-end request shows up as a trace across the pipeline.
The event ring — what just happened
For the everyday “did that message go through?” question there is a live in-memory ring of recent events:
- Panel: the Recent events card (refreshes every 4 s).
- API:
GET /events; agent:vejas_events.
Each entry carries the subject, ok/error, the emitted subjects, a payload preview, and — for sinks — what the downstream answered (a rejected fact, a skip), so you see not just that a message was sent but how it landed.
Health and failures
GET /healthz— liveness for a load balancer or k8s probe.- Anything that failed past its retries is not lost — it is parked with a verdict in the DLQ, which is its own inspectable surface.
Everything here is a read; none of it changes what runs. The performance cost of all three, measured, is in the benchmarks — the hot path stays a few-MB, few-thousand-per-second runtime with the windows open.
Golden-traffic curation
Fixtures written by hand go stale; real traffic is the truth. Golden curation turns one real event into a permanent test in one click:
- In the panel’s event ring, star ★ an event (or
POST /events/golden). - The runtime re-runs the flow on that event now and captures the actual emits as the expectation.
- What lands is a file:
tests/vjs/curated_<flow>_<n>.vjs— input event + expected emits — whichvejas-runtime vjs-test(and CI) runs forever.
That closes the expert→CI loop: the domain expert who recognizes a representative case doesn’t write a test — they point at reality, and the platform freezes it. When a flow’s behavior must change, time-travel and canary say what changes against history (changing safely); the curated cases say what must never change.
In a cluster, curation is refused on instances that hold no write access to the root (the cluster guard) — curate where the files live.
The certified catalog
Twenty recipes, every one admitted by CI — not “documented”, proven:
credential lint (no literal secrets, single-sourced pattern), parse, then a
real data-flow check. The recipes live in
docs/examples/connectors/
— copy the manifest, fill your instance URL, set the secret it references,
wire a flow.
| Family | Recipes | Certified against |
|---|---|---|
| SaaS (http-poll / oauth-poll / http-out) | ServiceNow ×2, Jira ×2, Slack, Workday RaaS, Stripe, SendGrid, GitHub, PagerDuty, Discord | A mock through the product’s own write path — data must flow |
| Webhook in | Shopify orders | Its own ingest: POST the fixture, see it on the bus |
| MQTT | source + sink | A real mosquitto on every CI run (incl. a 50-message QoS-1 burst) |
| AMQP/RabbitMQ | source + sink | A real RabbitMQ on every CI run (through the broker and back) |
| Kafka | source + sink (kcat bridge) | Stated exception + the offset-resume kill -9 test in CI |
| IBM MQ | source + sink (transactional binary) | Stated exception + a real-queue-manager verification transcript (MQCSP auth, live backout) |
Three certification regimes, honestly labeled:
- Mock-certified: the admission boots a throwaway runtime + the recipe’s mock; the probe must pass and a real message must flow.
- Real-broker-certified: the recipe ships
broker.sh(a throwaway container) anddataflow.sh; CI sends messages through the actual broker. - Stated exception: when a meaningful mock would be the system (a Kafka broker, a queue manager), the exception file says exactly what CI proves instead and what to verify against a dev instance — the first line is printed in the CI log, never silent.
Brokers: Kafka, MQTT, AMQP, IBM MQ
Each broker got the integration its protocol deserves — a deliberate spike-then-decide per broker (ADRs 0022, 0023, 0025, 0026, 0027):
| Broker | Shape | Why |
|---|---|---|
| MQTT | Hand-rolled 3.1.1 client, in-binary | The protocol is small; QoS 1 maps our at-least-once natively (source holds PUBACK until the bus confirms; sink acks the bus after PUBACK). Measured: 2 285 rt/s through a real mosquitto, QoS 1 both ways. |
| Kafka | Exec-bridge over kcat | The auth matrix (TLS, SASL, Kerberos) rides librdkafka in the child, never in our binary. Offsets in our KV: publish-before-commit, cadenced (100 ms), kill -9-proof in CI. |
| AMQP / RabbitMQ | First-class binary, pure Rust | amiquip (sync, no tokio) + TLS via rustls over its mio loop — no OpenSSL anywhere. Consume→pub-ack→ack; publish→confirm→ack. Certified against a real RabbitMQ in CI. |
| IBM MQ | First-class transactional binary | The no-loss guarantee is an ordering: MQGET under syncpoint → bus pub-ack → MQCMIT, in one process. Hand-declared MQI FFI, dlopen at runtime — builds with no MQ installed. MQCSP user/password auth. Verified live against a real queue manager: ordered drain, sink, and a bus-outage backout with zero loss. |
| Pulsar | Deferred, documented | The client crate imposes tokio + OpenSSL as hard deps and ~233 crates — the highest cost of the wave for the least established demand. The build path is written down for when a real user asks (ADR-0027). |
Operational notes that matter:
- Singleton because order (MQ, AMQP, Kafka sources): instances contend
on a KV lease; exactly one consumes.
*_COMPETING=1opts into competing consumers when throughput outranks global order — destructive reads make competing duplicate-safe. - IBM MQ packaging: the redistributable client needs its full directory
structure;
lib64alone segfaults insidelibmqic. PointVEJAS_MQ_LIBat<extract>/lib64/libmqic_r.so. - Recipes:
mqtt_source…mq_sink— env families in each.env.example.
SAP
The hard case that shaped the connector doctrine: a native protocol, a C SDK, IDocs — none of it belongs in the core binary.
The SAP connector is a standalone binary (connectors/sap-rfc) that
loads SAP NW RFC at runtime (hand-declared FFI + dlopen, ADR-0014 — the
same move later reused for IBM MQ): the build needs no SAP anywhere; only
the run needs the SDK. It registers as an RFC server (SAP_PROGRAM_ID at
the gateway) for inbound IDocs and speaks over the bus like every other
citizen.
Configuration is the SAP_* env family (SAP_ASHOST, SAP_SYSNR,
SAP_CLIENT, SAP_USER, SAP_PASSWD, SAP_PROGRAM_ID, gateway host and
service) — credentials from your secret machinery, never inline. The
request/response path (rpc:exec) is cluster-safe through a queue group:
each instance holds its own SAP logon, the bus distributes.
The end-to-end bridge (IDoc in → flow → response) is exercised in the recorded demo; a live SAP is required for the last mile, which is why the recipe carries a stated exception rather than a mock that would prove nothing.
VejasScript
The language reference below is extracted from the runtime’s own
vejas_language contract — the exact text every agent reads before writing
a flow. It is the source of truth; if this page and the tool ever disagree,
the tool wins.
VejasScript in 20 lines:
# comment
source "vx.domain.name" <- the flow's input subject, REQUIRED, line 1
SEVERITY_CODES = {"critique": "P1", "haute": "P2"} <- UPPERCASE literal dicts are transcoding tables the business expert edits
ALERT_LEVELS = ["P1", "P2"] <- UPPERCASE literal lists/scalars are editable constants
x = priority <- the incoming event's top-level fields are variables; `event` is the whole document
code = SEVERITY_CODES[priority] ?? "P3"
email = lower(requester?.email) <- builtins: upper lower trim len str num split join replace round abs; ?. is null-safe
ids = orders[].id <- array projection
big = orders[total > 100] <- array filtering
out = out + [{sku: l.sku}] <- array concatenation builds lists inside a for
fact = {source: "graph", in: 2} <- doc keys and .field names may be ANY word, keywords included
invoke format_alert(sev: code) <- compose a service from services/<name>.vjs; its outputs MERGE into this pipeline
d = invoke format_alert(sev: code) <- or capture its whole pipeline as a document
invoke pkg:svc(k: v) <- cross-package composition (the target package must list svc in its EXPORTS)
key = secret("slack/webhook") <- credentials resolve from the Vault at run time; NEVER a literal
if code in ALERT_LEVELS:
emit "vx.slack.out", {text: f"[{code}] {subject}"}
end <- every if/for closes with `end`
Exposing a flow (instead of, or besides, `source`):
tool "what calling this flow does" <- exposes the flow as an MCP tool
api "POST /orders" <- expose the flow as a SYNCHRONOUS HTTP endpoint under /api (POST /api/orders)
api "GET /orders/{id}" <- a REST resource = several flows, ONE per verb; {path params} become event variables (here `id`)
API_REQUEST = {customer: "string", total: "number"} <- optional: typed request schema for the generated OpenAPI
API_RESPONSE = {id: "string", status: "string"} <- optional: typed 200 response schema
respond 201, {id: id, status: "created"} <- the SYNCHRONOUS HTTP response (status code + JSON body); `emit` still fires bus side-effects
Rules:
- Known sinks: vx.slack.out (payload {text: "..."}). All subjects start with "vx.".
- Put every business-meaningful value (thresholds, tables, queue names) in UPPERCASE literals.
- A flow file's first line is `# flow: <snake_case_name>`; it lives under flows/ (or packages/<pkg>/flows/).
- Its sample input lives at flows/fixtures/<flow>.json (or packages/<pkg>/fixtures/) — one JSON event.
- A flow is triggered by ONE of: `source "vx…"` (bus), `tool "…"` (MCP), or `api "VERB /path"` (HTTP). An `api` flow answers with `respond <status>, {body}`; the request's JSON body, {path params} and `query` are all in the event. The whole API is described at GET /api/openapi.json.
- A connector manifest's first line is `# connector: <name>`, then `driver "<name>"` (catalog: vejas_drivers) and UPPERCASE literal config; any credential uses secret("path/key"), never a literal.
Design notes
- Pure by construction (ADR-0001): no I/O, clock, or network exists in
the language. Effects happen only at the edges (
emit,respond) — which is what makes replay, time-travel and canary structurally safe. - UPPERCASE literals are the contract with humans: anything a domain expert may want to change belongs in one (the business surface).
- Files are the deployment unit:
flows/<name>.vjs,connectors/<name>.vjs, fixtures next to them, golden tests undertests/vjs/. Git is the source of truth; the runtime supervises the directory.
Environment variables
Everything below is read from the environment at process start. Nothing is
mandatory for a dev run: vejas-runtime with a local NATS and a VEJAS_ROOT
directory is a complete system.
Core runtime
| Variable | Default | Purpose |
|---|---|---|
NATS_URL | nats://127.0.0.1:4222 | The bus. The only infrastructure dependency (ADR-0002). |
VEJAS_ROOT | . | Root directory: flows/, connectors/, tables/, tests/. |
VEJAS_HTTP_ADDR | 0.0.0.0:8686 | Panel + HTTP API + /mcp listen address. |
VEJAS_TOKEN | (unset) | When set, the whole mutating surface (including /mcp) requires Authorization: Bearer. |
VEJAS_SUBJECT_ROOT | vx | Bus subject prefix. |
VEJAS_STREAM | VEJAS | JetStream stream name the runtime creates/uses. |
VEJAS_ACK_WAIT_SECS | 30 | Redelivery window per consumer. Floor is 1s — JetStream silently rejects less. Cluster guidance: 3–5s. |
VEJAS_STATUS_SECS | 10 | Status heartbeat cadence. |
VEJAS_TENANT | (unset) | Tenant label for provisioned packages. |
VEJAS_BUNDLE | (unset) | Bundle path for provisioning (vejas_provision). |
VEJAS_AGENT_CMD | (unset) | Command the panel uses for its agent-assist box. |
Secrets (ADR-0008)
| Variable | Purpose |
|---|---|
VEJAS_SECRETS_FILE | File-backed secret store (dev). VEJAS_SECRET_<PATH> env entries also resolve. |
VAULT_ADDR / VAULT_TOKEN / VEJAS_VAULT_MOUNT | HashiCorp Vault backend for secret("path/key"). |
Clustering (ADR-0020)
| Variable | Default | Purpose |
|---|---|---|
VEJAS_CLUSTER | (off) | Enables the cluster guard: local file mutations are refused (409) — changes go through versions or proposals. |
VEJAS_INSTANCE | hostname | Instance identity for leases and audit. |
VEJAS_LEASE_TTL_SECS | 10 | Singleton-lease TTL: crash-failover bound. |
Governed mode (ADR-0024)
| Variable | Purpose |
|---|---|
VEJAS_REQUIRE_APPROVAL=1 | Every mutating path answers “submit a proposal instead”. |
VEJAS_APPROVAL_TOKEN | The human approval credential (X-Approval-Token header) — deliberately distinct from the agent’s VEJAS_TOKEN. The runtime refuses to start governed mode without it. |
Observability (ADR-0016)
| Variable | Purpose |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP/HTTP-JSON export (hand-rolled, no SDK tree). /metrics (Prometheus) is always on. |
OTEL_SERVICE_NAME | Service name for exported spans/metrics. |
API flows
| Variable | Purpose |
|---|---|
VEJAS_API_TITLE / VEJAS_API_VERSION / VEJAS_API_DESCRIPTION | OpenAPI metadata for flows exposed as APIs. |
Standalone connector binaries
Each first-class connector binary reads its own VEJAS_<NAME>_* family plus
NATS_URL and VEJAS_STREAM. The certified recipes under
docs/examples/connectors/
are the authoritative, linted reference for: IBM MQ (VEJAS_MQ_*, plus
VEJAS_MQ_LIB to point at the redistributable client and VEJAS_MQ_USER /
VEJAS_MQ_PASSWORD for MQCSP auth), AMQP/RabbitMQ (VEJAS_AMQP_*,
TLS via VEJAS_AMQP_TLS_CA / VEJAS_AMQP_TLS_SERVER_NAME), SAP
(SAP_*), and Salesforce (SF_*).
HTTP API
One listener (VEJAS_HTTP_ADDR, default :8686) serves the panel, the API
and MCP. With VEJAS_TOKEN set, every mutating route requires
Authorization: Bearer <token>. In governed mode (ADR-0024) mutating routes
answer with a didactic refusal pointing at the proposal queue.
Request headers
| Header | On | Purpose |
|---|---|---|
Authorization: Bearer <token> | every mutating route | Write protection, when VEJAS_TOKEN is set. |
X-Approval-Token: <token> | /proposals/{id}/approve|reject | The human approval credential, distinct from VEJAS_TOKEN (ADR-0024). |
X-Vejas-Actor: <id> | any mutating route | Optional. Records who made the change in the audit trail; absent, the actor is panel. The enterprise auth proxy sets it after authenticating a user (ADR-0030) — useful open too, for a single operator naming themselves. Trimmed, capped at 128 chars. |
Health & introspection
| Route | Purpose |
|---|---|
GET /healthz | Liveness. |
GET /metrics | Prometheus text format — hand-rolled, always on. |
GET / · GET /panel | The panel (single embedded HTML). |
GET /topology | Units and their wiring. |
GET /graph | The flow graph (drives the panel’s clickable diagram). |
GET /events | Recent traced events (ring). |
GET /drivers | Live driver catalog with config contracts. |
GET /rules | The rules view (ADR-0019): N1 parameters, N2 read-only projection. |
GET /surface | The business surface: editable literals, tables, spans. |
GET /evictions | Version-overlay evictions (git-wins, loud — ADR-0021). |
Files & surface
| Route | Purpose |
|---|---|
GET /file · POST /file/set | Read / write a flow or connector file (guarded paths). |
POST /surface/set | Edit one literal through the product write path (targeted restart). |
GET /fixture · POST /fixture/set | Per-flow test fixtures. |
POST /preview | Run a flow against a fixture without publishing. |
POST /flows/new · POST /connectors/new | Create from the panel/agent. |
POST /connectors/test | Connector probe (auth reachability, no writes). |
POST /reload | Reload units after out-of-band file changes. |
Failure handling
| Route | Purpose |
|---|---|
GET /dlq | Dead letters with death envelopes (version-tagged). |
POST /dlq/replay · POST /dlq/purge | Explicit replay / purge (ADR-0015). |
Versions, time-travel, canary (ADR-0021)
| Route | Purpose |
|---|---|
POST /surface/timetravel | Candidate vs live over a window of persisted real traffic. |
POST /surface/canary/start · /stop · GET /surface/canary | Shadow canary on live traffic. |
POST /surface/replay | Replay literal history (ADR-0018). |
POST /surface/rollback | Forward-only rollback of a literal. |
Governance (ADR-0024)
| Route | Purpose |
|---|---|
GET /proposals | The proposal queue. |
POST /proposals/{id}/approve · /reject | Human decision — requires X-Approval-Token (distinct credential). |
Curation & provisioning
| Route | Purpose |
|---|---|
POST /events/golden | Capture a ring event as a curated test case (golden traffic). |
POST /provision | Instantiate a tenant package from a template. |
GET /secrets · POST /secrets/set | Secret paths (values never returned). |
Agents
| Route | Purpose |
|---|---|
POST /mcp | JSON-RPC 2.0 — the runtime is the MCP server. See MCP tools. |
POST /ingest/<suffix> (http-in connector, own port) | Webhook ingestion → vx.<suffix> on the bus, 202 after the JetStream pub-ack. |
MCP tools
The runtime is the MCP server: JSON-RPC 2.0 over POST /mcp (ADR-0006).
Point any MCP client at http://<host>:8686/mcp; when VEJAS_TOKEN is set,
send it as Authorization: Bearer <token>. The list below is generated from
the tool descriptions the agent actually sees — they are the contract.
vejas_topology
List running flows and connectors with their status.
vejas_graph
The pipeline graph: sources, flows, composed services, destinations, connectors.
vejas_surface
The business surface of every flow: mappings, transcoding tables, constants.
vejas_language
The VejasScript reference: grammar, builtins, and the rules for flow files and connector manifests. Read this before writing any .vjs.
vejas_read
Read a script file (.vjs) or fixture (.json).
vejas_write_flow
Create or overwrite a .vjs script (parse-validated, hot-reloaded) or a .json fixture. path under flows/, connectors/, or packages/<pkg>/flows|services|fixtures.
vejas_set_literal
Rewrite one literal of the business surface in place (constant, or a table/mapping entry via key).
vejas_rollback_literal
Roll a business-surface literal back to the value it held before its most recent promote (from the VEJAS_AUDIT trail). Rollback is itself an audited promote to the recorded previous value â forward-only, hot-reloaded, previewable first with vejas_replay_literal. Returns {restored, was, rolled_back_promote_ts}.
vejas_time_travel
Time-travel (ADR-0021): replay a window of REAL persisted traffic through a whole CANDIDATE version of a flow and diff its emissions against the current effective version, joined by stream sequence. Read-only, the bus untouched, a candidate’s emits never reach a real subject. Use to preview an arbitrary rewrite (not just one literal) before promoting it. Returns {events, changed, results:[{seq, before, after, changed}]}.
vejas_canary_start
Start a canary (ADR-0021): shadow-follow a flow’s LIVE traffic and diff a candidate version against the current effective version as events arrive, accumulating a diff. Read-only (shadow â no real emit). Refuses if a canary is already running for the flow. Auto-stops if the live version changes under it (reason in status).
vejas_canary_status
Read a canary’s accumulating diff: {running, events, changed, stop_reason, results:[{seq, before, after, changed}]}.
vejas_canary_stop
Stop a running canary for a flow (its shadow consumer exits; the last diff stays readable).
vejas_propose
Submit a governed change PROPOSAL for a human to approve in the panel (ADR-0024) â you can propose but never approve. kind=‘set_literal’ (payload {file, name, key, value}) or kind=‘version’ (payload {file, candidate: whole source}). Attach evidence you gathered (vejas_time_travel results, vejas_canary stats) â the panel shows it next to Approve, and flags ‘no evidence’ loudly. The proposal is pinned to the current baseline and auto-expires if a deploy/promote moves it. Returns the stored proposal (id, status:pending).
vejas_proposals
List the proposal queue with status (pending/approved/rejected/expired) and evidence. Read-only. Approve/reject are human panel actions, not tools.
vejas_replay_literal
Shadow-replay a proposed literal change against REAL persisted traffic: hydrate the flow’s recent events from JetStream (read-only, the bus untouched â falls back to the in-memory trace ring when the stream is empty or the flow has no bus source), rerun them against the current AND the patched script, and return the before/after emit diff (with source: jetstream|trace-ring). Nothing is written â promote with vejas_set_literal.
vejas_preview
Run a flow on its fixture and return the emitted messages plus the final pipeline.
vejas_run_flow
Run any flow on a supplied input event and return its emits (does not touch the bus).
vejas_events
The most recent events processed by the flows â subject, ok/error, emitted subjects, payload preview â newest first. Optional filter: flow (e.g. “flow:stripe_alerts”).
vejas_reload
Rescan flows and packages; start new, stop removed, restart changed.
vejas_drivers
List the available connector drivers (name, kind, description) for writing connector manifests.
vejas_secrets
The secret references declared by flows and connectors, who uses each, and whether it RESOLVES against the store â references and statuses only, never values.
vejas_set_secret
Write one secret value into the store (rotation included) and restart the units that reference it. WRITE-ONLY: no surface ever returns the value.
vejas_test_connector
Synchronously test one connector instance: evaluate its manifest with the real secrets, reach the remote side with the driver’s probe, touch nothing. Returns {ok, detail} in plain words.
vejas_provision
Instantiate a tenant package from a template (templates/<name>/, ${param} substitution, every file parse-checked, hot-started). Returns created files, started units and the secret references left to write. Refuses an existing package unless force (which overwrites template-rendered files).
vejas_dlq
List dead letters â poison messages parked in the DLQ (ADR-0015) instead of dropped: unit, original subject, attempts, last error, payload, each with a seq for replay/purge. Newest first.
vejas_dlq_replay
Replay dead letters â re-inject each to its ORIGINAL subject so the (now corrected) flow reprocesses it, then remove it from the DLQ. Target one by seq, a whole unit, or all (omit both). Do this AFTER fixing the cause (vejas_set_literal, previewed with vejas_replay_literal).
vejas_dlq_purge
Discard dead letters without replaying â by seq, by unit, or all (omit both).
vejas_new_flow
Ask the agent to write a new VejasScript flow from a natural-language request; it lands running.
vejas_new_connector
Ask the agent to write a new connector manifest from a natural-language request (picks a driver, writes config, uses secret() for credentials); it lands running.
Subjects
The single source of truth for the subject convention and the driver
catalog is docs/SUBJECTS.md
in the repository — included verbatim below.
The subject convention (this is the whole connector interface)
Everything on the bus lives under one subject root: vx. (configurable via
VEJAS_SUBJECT_ROOT). One JetStream stream named VEJAS binds vx.>.
Bundled connectors are native Rust drivers run from a declarative manifest
(connectors/<name>.vjs: driver "..." + literal config, editable in the panel,
hot-addable). No subprocess, no Python. Drivers today:
- http-in (source:webhook) —
POST /ingest/<suffix>→vx.<suffix>. Config: PORT. - timer (source:interval) — emits PAYLOAD on SUBJECT every INTERVAL_SECS
(an object payload gains a
tsfield, ISO 8601 UTC, when absent). - http-poll (source:poll) — GETs URL every INTERVAL_SECS → SUBJECT. Optional HEADERS; optional
ENVELOPE = truepublishes{endpoint, fetched_at, body}(like oauth-poll) so a stateless flow gets acollected_at. - oauth-poll (source:poll) — OAuth2 client-credentials REST poller: token from
TOKEN_URL (CLIENT_SECRET via
secret()), GETs each of ENDPOINTS with the Bearer, pagination via NEXT_LINK_FIELD (default@odata.nextLink, absolute links followed as-is) capped by MAX_PAGES, publishes one{endpoint, fetched_at, body}message per page on SUBJECT. SCOPE is optional (omitted from the token form when empty — e.g. CrowdStrike).EXPAND = [{name, list, detail, key, as, list_field?}]adds a client-side $expand — every item of the list array (list_field, defaultvalue; a bare-string item becomes{key: id}, so CrowdStrike’sresources: [ids]→ per-id detail works) enriched with its detail call, the page shipped as one envelope — for list APIs without a server-side expand. One generic OAuth+REST driver stands in for most of a connector catalog. - slack-out (sink) — consumes vx.slack.out → Slack webhook / DRY-RUN.
- http-out (sink) — consumes SUBJECT → POST to URL. Optional HEADERS doc for
authenticated pushes, values via
secret():HEADERS = {"Authorization": f"Bearer {secret("acme/api_token")}"}. - mqtt-in (source) / mqtt-out (sink) — a hand-rolled synchronous MQTT 3.1.1 client, in-binary, zero dependency (ADR-0025). QoS 1 maps our at-least-once natively: the source holds the broker’s PUBACK until the bus publish is confirmed; the sink acks the bus only after the broker’s PUBACK. TLS / QoS 2 / MQTT 5 → the mosquitto exec-bridge escape hatch.
- mq source/sink (standalone binary,
connectors/mq) — IBM MQ as a first-class transactional citizen (ADR-0023): MQGET under syncpoint → bus publish (await pub-ack) → MQCMIT, and the mirror for the sink. Hand-declared MQI FFI,dlopenof the MQ client at runtime — builds with no MQ present. Configured by env (VEJAS_MQ_*, see the recipe), not by a manifest. - amqp source/sink (standalone binary,
connectors/amqp) — RabbitMQ / AMQP 0-9-1 as a first-class citizen (ADR-0026): pure Rust, sync, no tokio, TLS via rustls over amiquip’s mio loop (no OpenSSL). Source acks AMQP only after the bus pub-ack; sink acks the bus only after the publisher confirm. Configured by env (VEJAS_AMQP_*, see the recipe). Certified against a real RabbitMQ in CI. - exec-source / exec-sink — bridge an external program in ANY language over stdio (source prints JSON on stdout; sink reads JSON on stdin). The hot-add path for new connector types without recompiling the core or loading native libs (ADR-0011).
An external connector in any language is still a first-class citizen: it is just a process that follows these rules on the same bus. Language, host and supervisor are irrelevant.
- Publish JSON, UTF-8, on
vx.<domain>.<name>. - Consume with a durable pull consumer whose durable name identifies you.
- Ack a message only after its side effect succeeded; on failure,
nakwith a delay. Redelivery is the retry mechanism; make side effects idempotent. - Ensure the
VEJASstream exists before first use (idempotent create). - Expose nothing else. No registry, no manifest, no RPC handshake.
Flows (VejasScript) follow the same contract; the runtime does the boring parts
and guarantees every emit is published before the incoming message is acked,
so a crash means redelivery, never a lost emit.
One sibling root is reserved: vxc.<tenant>.> — the remote-collector
control channel (leaf-node uplink, closed command allowlist, local approval
for content changes). It lives OUTSIDE vx.> on purpose: control traffic is
transient and must never be captured by a stream (a stream on the command
subject would hijack request/reply with its pub-ack). Specification:
CONTROL.md, decision: ADR-0013.
Benchmarks
Every number quoted in these docs is reproducible from
bench/ — scripts, not
claims. Machine of record: an 8-core dev machine under WSL2; run them on
yours.
Current numbers
| Metric | Value | Reproduce with |
|---|---|---|
| Cold start (spawn → healthz) | 11–13 ms | bench/run.sh |
| Runtime RSS under load | 6–8 MB (49 MB with 50 live flows) | bench/run.sh, bench/multi-flow.sh |
| Binary / image | 6.2 MB / 201 MB | — |
| e2e latency, uncongested (webhook→flow→sink) | p50 2 ms, p99 3 ms | bench/paced.sh 20 15 |
| e2e paced sustained ~1 900/s | p50 14 ms, p99 36 ms, 20 000/20 000 | bench/paced.sh 2000 15 |
| e2e saturated (32 conns) | ~4 900/s ingest, ~2 650/s delivered (sink-bound) | bench/run.sh 15 32 |
| Isolated flow hop | 8 110/s (9 948/s over 10 flows) | bench/flow-only.sh, bench/multi-flow.sh |
| MQTT loopback, QoS 1 both ways, real mosquitto | 2 285 rt/s, 5 000/5 000 | bench/broker-mqtt.sh 5000 |
| Cluster: instance kill -9 under load | 20 000/20 000, zero loss | bench/cluster.sh |
| Cluster: singleton failover | ~2.6 s graceful / ~5.9 s crash (TTL-bound) | bench/cluster-gaps.sh |
| Cluster-wide version promote | 60 ms convergence, lossless mid-burst | bench/cluster-promote.sh |
Every hop persisted in JetStream throughout — the guarantee never moved
while these numbers were earned. Methodology, the five ceilings that fell
(and their causes), and the honest comparison table against Redpanda
Connect and n8n:
bench/README.md
and bench/compare/.
Architecture decisions
The platform’s memory: every consequential decision is an ADR — context, decision, consequences, and what was rejected. They are the honest answer to “why is it built this way”, and the moat a rewrite would have to re-earn.
- ADR-0001 — 0001 — VejasScript as the native flow language
- ADR-0002 — 0002 — NATS JetStream as the only infrastructure dependency
- ADR-0003 — 0003 — The package model
- ADR-0004 — 0004 — Service composition & EXPORTS visibility
- ADR-0005 — 0005 — Business surface: literals, corrected in place
- ADR-0006 — 0006 — The runtime is its own MCP server; flow-as-tool
- ADR-0007 — 0007 — Connectors: native bundled + bus contract + SDK
- ADR-0008 — 0008 — Secrets via a Vault, never in literals
- ADR-0009 — 0009 — All-Rust runtime, no Python
- ADR-0010 — 0010 — Transformation doctrine: small registry + code-by-example
- ADR-0011 — 0011 — Connector extensibility: external process, not native libs; WASM later
- ADR-0012 — 0012 — Deployment topologies: cells and outbound-only collectors
- ADR-0013 — 0013 — Remote control plane over NATS leaf nodes
- ADR-0014 — 0014 — SAP connector: native Rust over the NW RFC SDK, no JVM
- ADR-0015 — 0015 — Persistent dead-letter queue with operator replay
- ADR-0016 — 0016 — Observability: hand-rolled Prometheus
/metricsand OTLP trace export - ADR-0017 — 0017 — The connector admission test
- ADR-0018 — 0018 — Shadow-replay on persisted traffic (and the promote audit trail)
- ADR-0019 — 0019 — Rules-view: a read-only projection of a flow’s rules
- ADR-0020 — 0020 — Clustering and zero-downtime, the NATS-native way
- ADR-0021 — 0021 — Versioning, canary, and time-travel
- ADR-0022 — 0022 — Kafka connector: an exec-bridge over kcat, not a native client
- ADR-0023 — 0023 — IBM MQ: a first-class transactional connector, not an exec-bridge
- ADR-0024 — 0024 — The proposal queue: governed change, from agents and the fleet
- ADR-0025 — 0025 — MQTT: a hand-rolled sync client, not a dependency
- ADR-0026 — 0026 — AMQP 0-9-1 (RabbitMQ): a sync connector on amiquip
- ADR-0027 — 0027 — Apache Pulsar: spike, and a deliberate defer
- ADR-0028 — 0028 — The open-core boundary
- ADR-0029 — 0029 — The failure-mode guardrails