This note explains the split between the OSS @agent-assistant/* packages and a hosted proactive runtime, and shows the recommended recipe for using them together.
@agent-assistant/proactive is the in-process layer. It stays inside one handler invocation and one assistant. It owns follow-up engines, watch-rule evaluation, and scheduler bindings that request another wake-up from inside the handler.
The proactive runtime is the durable cloud layer. It owns cross-process triggers, multi-trigger fan-in, durable delivery, retry, and the hosted control plane that decides when your handler should wake up.
The shorthand is:
@agent-assistant/proactive= in-process follow-up engines, watch rules, and scheduler bindings- proactive runtime = cross-process, multi-trigger, durable trigger surface
Use @agent-assistant inside onEvent. The runtime gives you the trigger. @agent-assistant gives you the stateful conversation and follow-up logic that runs after the wake-up.
Use @agent-assistant/proactive for:
- follow-up engines
- watch-rule evaluation
- scheduler binding contracts
- assistant-session-aware logic inside one handler
- stateful conversation and follow-up logic after wake-up
Use the proactive runtime for:
- durable trigger delivery
- cross-process wake-ups
- multi-trigger fan-in
- hosted scheduling and dispatch
- long-lived runtime state outside a single process
Treat the runtime event as the reason to wake the assistant, not as a replacement for assistant state. Inside onEvent, recover or create an @agent-assistant/sessions session, then run the normal proactive engine or follow-up logic against that session.
import { agent } from '@agent-relay/agent';
import {
ContextSchedulerBinding,
createProactiveEngine,
fromContext,
} from '@agent-assistant/proactive';
import {
createSessionStore,
ctxFilesToRuntimeSessionStoreAdapterOptions,
RuntimeSessionStoreAdapter,
} from '@agent-assistant/sessions';
await agent({
workspace: 'your-workspace',
schedule: '*/5 * * * *',
onEvent: async (ctx, event) => {
const sessions = createSessionStore({
adapter: new RuntimeSessionStoreAdapter(
ctxFilesToRuntimeSessionStoreAdapterOptions(ctx.files, {
signal: ctx.signal,
}),
),
});
const engine = createProactiveEngine({
schedulerBinding: new ContextSchedulerBinding(ctx),
});
const aaSession = fromContext(ctx);
ctx.signal.throwIfAborted?.();
const session =
(await sessions.get(aaSession.id)) ??
(await sessions.create({
id: aaSession.id,
userId: aaSession.userId,
workspaceId: aaSession.workspaceId,
initialSurfaceId: aaSession.initialSurfaceId,
metadata: aaSession.metadata,
}));
const decisions = await engine.evaluateFollowUp({
sessionId: session.id,
scheduledAt: new Date().toISOString(),
lastActivityAt: session.lastActivityAt ?? session.createdAt ?? new Date().toISOString(),
});
ctx.signal.throwIfAborted?.();
for (const decision of decisions) {
if (decision.action === 'fire') {
await ctx.once(`followup:${session.id}:${decision.ruleId}:${event.id}`, async () => {
await deliverFollowUp(decision, session, ctx.signal);
});
}
}
},
});That keeps responsibilities clean:
- the runtime decides when the handler wakes up
fromContext(ctx)gives you a stable assistant-session key@agent-assistant/sessionspreserves continuity across wake-ups@agent-assistant/proactivedecides what follow-up should happen next
deliverFollowUp(decision, session) is product-owned. A minimal implementation is:
async function deliverFollowUp(
decision: FollowUpDecision,
session: Session,
signal?: AbortSignal,
) {
signal?.throwIfAborted?.();
await sendAssistantMessage({
sessionId: session.id,
text: decision.messageTemplate ?? 'Following up on your request.',
metadata: {
ruleId: decision.ruleId,
routingHint: decision.routingHint,
},
});
signal?.throwIfAborted?.();
}The runtime does not provide this helper because the delivery surface differs by product. Some products write back into relaycast, some enqueue a hosted assistant turn, and some persist a task for a later human review queue.
The recipe uses RuntimeSessionStoreAdapter so assistant-session continuity survives cold starts. ctxFilesToRuntimeSessionStoreAdapterOptions(ctx.files, { signal: ctx.signal }) is the shared bridge helper for that wiring, which avoids re-implementing the same read/write/list/delete adapter glue in every hosted runtime. InMemorySessionStoreAdapter is still useful for tests and single-process demos, but production proactive-runtime handlers should back @agent-assistant/sessions with ctx.files or another durable store.
Runtime contexts usually contain a workspace identifier and an agent identifier, but not the shape that @agent-assistant/sessions wants. fromContext(ctx) converts that runtime context into a stable assistant-session descriptor keyed on (workspace, agentId) so consumers do not have to hand-roll the mapping.
The helper returns:
idfor the session iduserIdscoped to the agent tupleworkspaceIdsurfaceIdas a convenience alias for the runtime-facing attachment idinitialSurfaceIdforsessions.create(...), which is the field the session store consumes when attaching the first surfacemetadatawith the runtime source and stable session key
That keeps the same agent in two different workspaces from collapsing into one session bucket, and it keeps repeat wake-ups for the same (workspace, agentId) pair attached to the same assistant session identity.
fromContext is intentionally structural and cloud-agnostic:
- it accepts either
ctx.workspaceIdorctx.workspace.id - it also accepts the canonical runtime shape
ctx.workspace: stringwithctx.agentId: string - it accepts either
ctx.agentIdorctx.agent.id - it trims blank ids and prefers the first valid value
- it derives a stable assistant identity from
(workspace, agentId) - it does not import any hosted runtime package
That preserves the OSS boundary: cloud layers may depend on @agent-assistant/*, but OSS packages here do not depend on the hosted runtime.
When the hosted runtime only exposes ctx.workspace as a human-readable
workspace name, fromContext(ctx) uses that value as the stable
workspaceId. That keeps the helper structural and runtime-agnostic, but it
means renaming a workspace changes the derived session id and therefore starts
a fresh assistant session. Hosted runtimes that need rename-stable continuity
should pass a durable ctx.workspaceId instead of only ctx.workspace.
The hosted runtime delivers wake-up events with at-least-once semantics, so
evaluateFollowUp() and the follow-up side effects around it must be safe under
redelivery. The recommended pattern is:
- use
ctx.once(...)or an equivalent runtime idempotency primitive around the outward side effect - key that idempotency scope with the assistant session id, the follow-up rule id, and the runtime event id
- treat repeated
evaluateFollowUp()calls as expected after retry or reconnect
The recipe above follows that pattern with
followup:${session.id}:${decision.ruleId}:${event.id} so the engine may be
re-run while the externally visible follow-up is still deduplicated.