Durable ambient attention for Eve agents.
Agents are most useful when they can notice what is happening around them, not only when someone sends them a direct prompt. But every message, webhook, alert, or state change does not deserve an agent run.
Eve Ambient sits between event intake and agent cognition. Applications define typed channels and attention rules. Ambient selects relevant events, correlates related activity, buffers it by key, makes a bounded decision, and wakes Eve only when attention is warranted.
channel events signal pipeline durable log consumer
| | |
+---------------------+-----------------------+
|
publish()
|
select and group by correlation
| |
x ignore v
correlation Workflow
ring / buffer / durable timer
|
prepare()
ignore x | wake
v
checkpoint
|
deliver()
|
v
Eve session
Ambient is not an event bus, replay system, or general workflow engine. Channel adapters and event infrastructure still own transport, raw retention, and normalization. Ambient owns the smaller question: which normalized events merit cognition, and how does that decision survive duplicates, restarts, and failed delivery?
The simple version of ambient attention is application glue: a webhook, an in-process debounce, a classifier, and a call to an agent. It works until a process restarts, an event arrives twice, a hot correlation key overwhelms a worker, or the classifier succeeds and the final handoff fails.
Eve Ambient makes those boundaries explicit. Each correlation address has one
serialized Workflow run. Active handoffs carry complete values rather than payload
references. A wake is checkpointed before delivery, and retries reuse the same
bytes and durable wakeKey.
That gives applications one attention model without pretending that every event system has the same shape.
Suppose a bot is present in 100 Slack channels. You want one rule to watch each channel and invoke a turn when someone says “message A” and a later message in the same collection window says “message B.”
Everything below comes from @ewhauser/eve-ambient or is defined in the
example itself:
pnpm add @ewhauser/eve-ambient@^0.6.0 workflow@5.0.0-beta.42Your Slack adapter verifies the webhook and supplies this small normalized
input. The channel contract converts it into the complete event Ambient hashes
and routes. Choosing channelId as partitionKey creates one correlation per
Slack channel for this rule—not one correlation per message.
import {
defineChannelCanonicalization,
type CanonicalChannelEvent,
} from "@ewhauser/eve-ambient";
interface SlackMessageInput {
eventId: string;
occurredAt: string;
tenantId: string;
workspaceId: string;
channelId: string;
userId: string;
text: string;
}
type SlackMessageEvent = CanonicalChannelEvent<
"slack.message",
{ workspaceId: string; channelId: string; text: string },
string
>;
const slackMessages = defineChannelCanonicalization<
SlackMessageInput,
SlackMessageEvent
>({
version: 1,
partitionKey: event => event.data.channelId,
canonicalize: input => ({
id: input.eventId,
type: "slack.message",
version: 1,
occurredAt: input.occurredAt,
data: {
workspaceId: input.workspaceId,
channelId: input.channelId,
text: input.text,
},
source: {
channelId: "slack",
installationId: input.workspaceId,
tenantId: input.tenantId,
},
actor: { id: input.userId, principalType: "user" },
replyTarget: `slack:${input.workspaceId}:${input.channelId}`,
origin: { kind: "external", depth: 0 },
}),
});Authentication and transport acknowledgement remain the Slack adapter's job.
The stable eventId must survive provider retries.
The rule admits only A and B messages. Its default correlation is one run
per rule inside the channel partition. The debounce window gives related
messages time to arrive before decide() receives the ordered batch. Ambient
orders it by the canonical occurredAt value, so preserve Slack's event
timestamp during normalization.
import {
debounce,
defineAmbientRule,
ignore,
wake,
} from "@ewhauser/eve-ambient";
const text = (event: SlackMessageEvent) =>
event.data.text.trim().toLowerCase();
const messageSequence = defineAmbientRule({
id: "message-a-then-b",
version: "v1",
channel: slackMessages,
matches: event => ["message a", "message b"].includes(text(event)),
policy: debounce({
quiet: "2m",
maxWait: "10m",
cooldown: "30m",
maxEvents: 48,
}),
decide({ events, eventKeys }) {
const firstA = events.findIndex(event => text(event) === "message a");
const followingB = events.findIndex(
(event, index) => index > firstA && text(event) === "message b",
);
if (firstA < 0 || followingB < 0) {
return ignore({ reason: "the batch has no A-then-B sequence" });
}
return wake({
routeId: "turns",
target: events[followingB]!.replyTarget!,
instruction:
"Review the Slack conversation and take the configured follow-up action.",
decision: { reason: "message A was followed by message B" },
evidence: {
channelId: events[followingB]!.data.channelId,
matchedEventKeys: [eventKeys[firstA]!, eventKeys[followingB]!],
},
});
},
});This detects the sequence inside one frozen batch. Ambient intentionally does not retain arbitrary rule history after a batch completes, so an unbounded “A at any time, then B days later” rule belongs in application-owned state.
The final side effect is an explicit application dependency. A TurnSink can
be backed by Eve or another durable agent queue; it must deduplicate on the
supplied idempotencyKey.
import {
defineAmbientApplication,
type JsonValue,
} from "@ewhauser/eve-ambient";
interface TurnSink {
enqueue(request: {
idempotencyKey: string;
address: string;
instruction: string;
evidence: JsonValue;
}): Promise<JsonValue>;
}
function address(target: JsonValue): string {
if (typeof target !== "string") {
throw new TypeError("the Slack turn target must be a string");
}
return target;
}
const definition = (turns: TurnSink) => defineAmbientApplication({
applicationId: "slack-sequence-agent",
rules: [messageSequence],
routes: [{
id: "turns",
deliver: wake => turns.enqueue({
idempotencyKey: wake.wakeKey,
address: address(wake.target),
instruction: wake.instruction,
evidence: wake.evidence,
}),
}],
});No TurnSink implementation is hidden in Ambient: the application supplies
it, and the interface above is its complete contract. The stable wakeKey
becomes the downstream turn admission key.
For local development, bind the definition to the included memory backend:
import { memory } from "@ewhauser/eve-ambient/memory";
export function createLocalApplication(turns: TurnSink) {
return definition(turns).with(memory());
}Publish each verified Slack input with
ambient.publish(slackMessages, input). In deterministic tests, advance the
injected clock past the debounce deadline before calling
ambient.engine.runDue().
In production, bind the same definition to Workflow. The application supplies its public callback URL; Workflow selects the configured standard World:
import { workflow } from "@ewhauser/eve-ambient/workflow";
export function createProductionApplication(turns: TurnSink) {
return definition(turns).with(workflow({
callbackUrl: "https://agent.example.com",
callbackSecretEnv: "AMBIENT_CALLBACK_SECRET",
}));
}Re-export Ambient's packaged workflow from a file in the application's
workflows/ directory so the Workflow compiler discovers it:
// workflows/ambient.ts
export * from "@ewhauser/eve-ambient/workflows";Configure Workflow for the application's framework, and mount the returned
fetch handler at its authenticated prepare and deliver callback paths.
When a trusted transport layer already authenticates and authorizes the
callback caller, explicitly disable the redundant bearer token on both the
Workflow steps and callback handler with callbackAuth: "none":
definition(turns).with(workflow({
callbackUrl: "http://ambient.internal",
callbackAuth: "none",
}));Do not use this mode for a callback endpoint that is reachable without an independently enforced workload-identity policy.
The complete typechecked example is
examples/slack-sequence/src/slack-message-sequence.ts.
With 100 joined channels, this produces at most 100 active correlation runs for
this rule version. If every channel receives A followed by B, admission makes
200 hook-resume operations into those same 100 runs. After the quiet period,
there are up to 100 distinct wakes—not 200 workflows. Prepare and delivery are
at-least-once steps, so callbacks may repeat; the TurnSink deduplicates them
by wakeKey.
The same shape applies beyond this Slack sequence:
| Listen to | Correlate by | Invoke a turn when |
|---|---|---|
| Deployments, errors, and health checks | service + deployment | a rollout remains unhealthy after its signals settle |
| Support tickets, replies, and account changes | customer + case | severity or SLA risk crosses a threshold |
| Identity, endpoint, and cloud detections | principal + investigation | several weak signals form one actionable incident |
| Orders, payments, and fulfillment events | order | the latest state needs reconciliation or human judgment |
| Scheduled snapshots and configuration changes | resource | drift persists across observations |
Some of these listeners can publish raw channel events. Others can be the output of an existing signal pipeline. Ambient starts at whichever boundary your application considers trustworthy and useful.
There is no single right place to reduce event volume. The publishing API is the boundary, so applications can choose the path that fits their existing system.
| Path | What enters Ambient | What stays outside | Good fit |
|---|---|---|---|
| Publish channel events | Normalized Slack, GitHub, webhook, scheduled, or application events | Provider delivery and raw retention | Applications that want Ambient rules to perform the first meaningful selection |
| Bring your own high-signal events | Events already selected by a SIEM, rules engine, stream processor, or domain-specific detector | Raw firehose, broad filtering, and detection pipelines | Organizations that already know what “interesting” means or cannot send the full event rate to an agent system |
| Consume a durable log | Events published by a Kafka or similar log consumer | Offsets, replay, long-term retention, and consumer scaling | High-volume systems that need ingestion to scale independently from correlation and cognition |
These paths can coexist. A single application might publish GitHub events
directly, consume Slack signals from Kafka, and accept incidents from an
existing detection service. Once an event crosses publish(), the same typed
rules, correlation protocol, and final idempotency boundary apply.
Ambient is a Workflow library, so it uses the standard World selected by the
Workflow runtime. There is no Ambient-specific AttentionWorld interface or
adapter. The chosen World owns Workflow storage, queues, streams, encryption,
retention, and observability; Ambient's correlation run owns the bounded ring,
batching state, timers, retries, and prepared wake.
Ambient 0.6 has concrete deployment paths for these Worlds:
| World | Install and select it | Operational requirement |
|---|---|---|
| Vercel | Deploy the Workflow application to Vercel; the managed World is selected automatically | Enable Fluid compute; Vercel owns storage, queues, authentication, and observability |
| Postgres | Install @workflow/world-postgres@beta, set WORKFLOW_TARGET_WORLD=@workflow/world-postgres and WORKFLOW_POSTGRES_URL, then run its idempotent bootstrap command |
Run world.start() in a long-lived process so Graphile Worker can poll; this is not a serverless backend |
world-celld |
Install @ewhauser/world-celld@^0.3.0, set WORKFLOW_TARGET_WORLD=@ewhauser/world-celld, CELLD_FLEET_URL, CELLD_WORLD_SECRET, and WORKFLOW_BASE_URL, then deploy its packaged worker |
Operate a celld fleet and a conditional-write-capable object store; the backend is experimental |
The Postgres package must come from its beta npm channel while Ambient uses
Workflow 5; its npm latest tag is still the Workflow 4 line. The linked setup
pages include runnable examples, migrations or worker deployment, and the
required application startup hooks.
The full Worlds directory remains the place to explore other official and community implementations. Inclusion there is not an Ambient compatibility claim: before deploying another World, verify that its published package implements the Workflow 5 contract and that its queue, hook, timer, and stream conformance tests pass.
For deterministic tests, the package includes memory(). It implements the
same stream reducer and explicit runDue() scheduling, but it is not a
production persistence backend.
For each inbound event, Ambient:
- canonicalizes the typed channel event and derives stable identity;
- runs deterministic rule selection and correlation;
- groups selected branches by correlation address;
- queues each append by deterministic hook token and matching operational limits for a 5 ms process-local flush window;
- sends each bounded same-token chunk as one
append-manyhook command; - resumes a cached hook owner when available, otherwise coalesces the initial token probe within the operational lane;
- on a miss, starts one candidate seeded with the complete first chunk, waits for hook ownership with jittered exponential backoff, and routes a losing candidate's chunk to the elected owner; and
- lets each run apply every append sequentially before buffering, preparing, checkpointing, and delivering independently.
0 selected correlations -> 0 Workflow calls
caching warm burst -> 1 resumeHook(owner) per bounded chunk
uncached warm burst -> 1 resumeHook(token) per bounded chunk
cold first chunk -> 1 failed resumeHook() + 1 seeded start() + lookups
Multiple matching rules that share a correlation are grouped into the same
append. Concurrent accept() calls synchronously register a process-local
preparation cohort from the complete correlation-token preimage and matching
operational settings. The cohort retains only counts and ready queue references;
validated commands are still batched exclusively by their final hook token.
Different tokens, operational lanes, and processes never share a batch. The hook
protocol accepts only append-many; this is a hard command-shape cutover with no
legacy decoder.
The 5 ms timer window was selected in the checked-in local integration after a 2 ms window split the 20-event cold burst under CI and full-suite load. Repeated 5 ms standalone and full-check runs each produced one warm resume and one seeded cold start. Once all already-registered same-lane accepts finish preparation, the queue starts that window. A ready append escapes a stalled peer after 50 ms and starts its own flush, so no preparation cohort can strand it. A 10 ms escape split a local 20-event cold burst; 50 ms produced the target across three consecutive runs. A lone event finishes its cohort immediately and therefore waits for one nominal 5 ms window; event-loop load can delay timers further. The defaults cap a command at 64 appends and 16 MiB of canonical serialized bytes, and chunking also respects the reducer's pending-branch and pending-byte limits. Entries retain their process-local queue-enrollment order. Chunks publish serially; if one fails, that chunk and all later, unsent chunks from the flush reject without being reordered around the failure.
Cold initialization remains singleflight per process, hook token, and matching
operational lane. The winning candidate receives the complete first chunk in
start(), so that publisher does not perform a second resumeHook().
Candidates from other lanes or processes still converge through deterministic
hook ownership; a losing publisher resumes the owner with its candidate's
chunk.
Resolved hook owners are reused across engine instances through a process-local 1,024-entry LRU with a 10-minute idle TTL. A missing cached owner is evicted and the unchanged batch retries through its deterministic token. The cache and probe gate are advisory optimizations over standard Workflow APIs and require no additional infrastructure.
The Workflow runtime expands that protocol call into internal World activity. The checked-in Workflow 5.0.0-beta.42 integration currently observes:
| Path | Ambient protocol calls | Standard World method calls | Application HTTP |
|---|---|---|---|
| Cold 20-event buffer-only burst | 1 failed resumeHook(), 1 seeded start(), 2-3 registration lookups |
13-14 observed | 0 |
| Cached warm 20-event buffer-only burst | 1 resumeHook()
|
6 | 0 |
| Cached close, prepare, and deliver | 1 resumeHook()
|
14 | 2 |
The 6 internal calls are one run read, three event writes, and two queue publishes; cached publication avoids the prior hook lookup. Repeated local standalone and full-check 20-event runs measured 15.8-26.9 ms warm admission and 58.2-68.3 ms cold admission; two Node 24 CI runs measured 42.1-52.9 ms warm and 106.6-142.0 ms cold with the same one-resume/seeded-start shape. These runs used 2-3 public registration lookups and 3-4 World hook lookups. Cold counts vary with scheduler timing: the same suite's single-append cold path used 16 World calls and six hook lookups. The previous fixed 5 ms polling loop used 27 World calls and 12 lookups. These counts describe the instrumented runtime and local test World, not a requirement imposed on every World. Deployed counts and latency also include the chosen World's network, database, scheduler, and regional behavior.
Each run retains a bounded recent-message ring for best-effort admission
deduplication. Ring eviction may allow an old event to be processed again, so
final effects do not depend on that cache. The durable receiver must enforce
the stable wakeKey.
Applied full-value reducer state is capped per correlation (1,000 pending
branches and 16 MiB by default). Each append-many command is also bounded and
cannot contain more branches or branch bytes than an empty reducer could hold.
The run processes its entries sequentially. Once either reducer limit is
reached, it retains only the remainder of that one bounded command and stops
consuming the hook until due work releases capacity; later commands remain in
Workflow's durable queue. A single append larger than either configured limit
or than the serialized command limit is rejected before resumeHook().
The process-local backlog is separately capped per operational lane at 1,000
accepted appends and 64 MiB of canonical append bytes by default, counting both
queued and currently publishing work. Admission beyond either limit rejects
with retryable WorkflowAdmissionBackpressureError; callers should retry with
their original stable identities after capacity becomes available. These local
limits do not change the deterministic hook token.
Correlation runs are intentionally permanent: there is no automatic rotation or handoff protocol. Live reducer state remains bounded, but the underlying Workflow event history grows while a correlation stays active. Operators should monitor per-run history limits and choose correlation keys with bounded traffic; a future standard continue-as-new primitive can add compaction without reintroducing a custom World contract.
The deterministic hook token includes a fingerprint of immutable Workflow options. Changing callback routing, retry, ring, lease, or capacity options therefore starts a new owner for subsequently admitted events instead of silently reusing an old run's captured configuration. Reducer state does not migrate across that cutover; drain or explicitly abandon the previous owner.
There is no event coordinator, global attention run, custom storage adapter, or global correlation registry in the admission path.
| Workspace | Purpose | Published |
|---|---|---|
packages/ambient |
Rules, reducer, memory reference, and packaged Workflow runtime | @ewhauser/eve-ambient |
examples/slack-sequence |
Complete typechecked Slack A-then-B application | No |
integration/workflow-correlation |
Consumer discovery, concurrency, retry, permanence, and call-count checks | No |
- Correlation Workflow protocol
- Workflow World deployment
- Monitoring and rules
- Operations and security
- Architecture decision index
- RFC 0005: Permanent correlation Workflows
corepack enable pnpm
pnpm install
pnpm check