Vancetope — Ursahooks
Ursahooks are project-specific, lifecycle-event-driven triggers. Conceptually, they are the same as Schedulers and Events — but with a Brain-internal trigger (Process completed, Inbox item created, Insight saved, …) instead of time (Scheduler) or HTTP (Event). They are stored as YAML documents under
_vance/hooks/<event>/<name>.yamlin the Document Layer and fire aTriggerAction(recipe:/script:/workflow:) through the centralActionExecutorRegistry.Ursahook runs are logged in the generic Event Log (see scheduler §7) with
source = hook:<event>:<name>.
1. Terms and Delimitation
| Term | What it is |
|---|---|
| Lifecycle Event | A Brain-internal point registered in UrsaHookEventName (e.g., insight.saved, process.completed). Statically cataloged, not user-definable. |
| Ursahook Doc | YAML document under _vance/hooks/<event>/<name>.yaml in the Project or _tenant. The filename (without .yaml) is the Ursahook name within the Event. |
| UrsaHookDispatcher | Spring Bean in vance-brain that fans out a fired Lifecycle Event to all active Ursahooks and calls actionExecutorRegistry.execute(action, ctx, TriggerKind.HOOK) for each Ursahook. |
| Ursahook Action | The TriggerAction from the Ursahook YAML — one of three variants (Recipe, Script, Workflow). The Dispatcher passes it unchanged to the Action Executor pipeline. |
Relationship to other Trigger Sources:
| Trigger | Occasion | One YAML per occasion with a TriggerAction |
|---|---|---|
| Scheduler | Cron / One-time date | _vance/scheduler/<name>.yaml |
| Event | Inbound HTTP (POST /brain/.../event/...) |
_vance/events/<name>.yaml |
| Ursahook | Brain-internal Lifecycle Event | _vance/hooks/<event>/<name>.yaml |
All three use the same TriggerAction contract and the same
ActionExecutorRegistry. Ursahooks are no longer a special form with
their own side-effect surface; they are a full-fledged trigger source.
In particular, Ursahooks may fire all three Action variants —
including recipe:, which spawns a ThinkProcess. The old
“Ursahooks-may-not-spawn” rule has been removed with the pipeline unification;
on the sandbox side, the TRIGGER_SCOPED mode governs what Scripts
are allowed to do within an Ursahook trigger (see trigger-actions §8).
2. Storage Layout & Cascade
<project>/_vance/hooks/<event>/<name>.yaml → Project Ursahook
_vance/hooks/<event>/<name>.yaml → Tenant Ursahook (applies to all projects of the Tenant)
Lookup for a fired Event in a specific Project:
load(tenantId, projectId, event) → List<UrsaHookDef> :=
documentService.listByPrefixCascade(tenantId, projectId, "_vance/hooks/" + event + "/")
1. Project-Layer → source = PROJECT
2. _tenant-Layer → source = TENANT
Merge by Ursahook Name: A Project Ursahook completely replaces a Tenant Ursahook of the same name (no field merge). Tenant Ursahooks without a Project override of the same name are active in all projects of the Tenant. The execution order per Event is not specified — Ursahooks are independent and must not rely on each other.
There are no Resource Layer defaults. Bundled Ursahooks make no sense — no Vancetope default should write to external systems. To provide standard Ursahooks for a project bootstrap, deliver them via a Kit (see kits — Ursahook files are Documents and are included by the Kit Apply).
3. Event Catalog (v1)
The catalog is centralized as an UrsaHookEventName enum in vance-api; the path key is the wire form (e.g., process.completed). New events are added only via spec extension. For each event, the payload schema of the event object is defined.
| Event | Status | When it fires | Payload Highlights |
|---|---|---|---|
process.completed |
active | ThinkProcessStatusChangedEvent with closeReason = DONE |
event.process.{id, name, title, sessionId, engine, recipe, closeReason, status, parentProcessId} |
process.failed |
active | Status change with closeReason ∈ {STALE, STOPPED, AUTO_CLOSE, ARCHIVED, USER_DELETE, ABANDONED} |
same as above |
inbox.item.created |
active | MaximegalonCreatedEvent (every MaximegalonService.create(...)) |
event.item.{id, type, title, body, criticality, tags, requiresAction, recipientUserId, originatorUserId, processId?, sessionId?} |
session.suspended |
reserved — Documents are accepted, Ursahook does not fire. No Spring Event in SessionService.suspend(...) (v2). |
— | event.session.{id, name, projectId, reason} (planned) |
session.resumed |
reserved — same as above for SessionService.resume(...). |
— | event.session.{id, name, projectId} (planned) |
insight.saved |
reserved — Knowledge Graph Service Layer does not yet exist. | — | event.insight.{name, title, body, scope, tags, sourceProcessId} (planned) |
relation.created |
reserved — same as above for Relations. | — | event.relation.{from, to, type, label, weight} (planned) |
Reserved Events are listed in the enum and are supported through all cascade/validation paths — an Ursahook document under _vance/hooks/session.suspended/... loads cleanly. As soon as the respective Domain Service publishes an UrsaHookFireableEvent, the Ursahook fires without code changes to the Ursahook layer.
What is not an Event (v1): Tool Calls (tool.before/tool.after), LLM Calls, Engine Lifecycle steps, User Login, Document Save. Rationale: Outbound for these points is not clearly required today, and tool.before would be an Inbound Ursahook (decides whether a call happens) — this is explicitly a v2 topic (see §11).
No
document.*-Change Hook — conscious, permanent decision (2026-06-29). A hook that fires on Document Writes (document.created/document.updated) will not be introduced. It would enable flip-flop cascades: a write triggers a hook → its action (Script/Recipe) writes another Document → that triggers another hook → … (possibly with an LLM in between, so that the output never converges byte-identically and a content-hash short-circuit also does not apply). A suitable counter-mechanism (a “stack trace” / cascade guard carried through the chain that detects depth and flip-flops and aborts) cannot be implemented practically: The context would have to travel fail-closed through every carrier — Recipe Spawn, Script (JS in-JVM and Python subprocess), Workflow Run, Inbox Item, and the Document Event itself (including cross-pod). Every uncovered carrier is a hole through which an unbridled cascade escapes; a net with holes is no net at all. Analysis and discarded options:planning/ursa-cascade-guard.md. Those who need dynamic, derived Document updates should build them explicitly terminating into an Engine/a Script (deterministic DAG that decides when to stop) — not via a self-triggering hook chain. The specific pattern for this is the Application Refresh model: triggered, finite transformation by click (app_rebuild) or one-time save trigger — seedoc-kind-application§1 “Update is triggered, not cascading”.
Sync vs. Async: All v1 events fire asynchronously via ApplicationEventPublisher → @EventListener @Async listener of the Dispatcher. There is no synchronous event path in v1; domain operations never block on Ursahook completion.
4. Ursahook Definition
One YAML file per Ursahook. The action schema is identical to Scheduler
and Event — see specification/trigger-actions.md
for the complete variant description. Exactly one of the three
Action variants is set at the top level: recipe:, script: or
workflow:.
| Field | Type | Required | Meaning |
|---|---|---|---|
recipe / script / workflow |
— | exactly one | The TriggerAction variant (disjunction enforced) |
params |
Map<String,Object> |
no | Passed to the Executor. The Dispatcher additionally merges the Event Payload under params.event |
initialMessage |
String |
recipe: only |
First USER message for the spawned ThinkProcess |
runAs |
String |
no | User identity. Default: createdByUserId of the Ursahook Document |
enabled |
boolean |
default true |
Pause without deletion |
description |
String |
recommended | One line — UI, hook_list |
timeout |
Duration |
default 5s |
Per-Ursahook wall-clock ceiling (max. 30s). Per Action, the action-specific timeout also applies (e.g., script.timeoutSeconds) |
tags |
List<String> |
no | Free for discovery |
4.1 Example — Recipe Ursahook
Spawns a Process via Recipe when a Process completes:
# _vance/hooks/process.completed/triage-notes.yaml
enabled: true
description: Triage notes for completed Analyze runs
recipe: notes-triage
params:
parentProcessId: "${event.process.id}"
initialMessage: "Please triage the notes of the parent process."
4.2 Example — Script Ursahook
Executes a Document Script when an Inbox Item is created. The
Script runs in TRIGGER_SCOPED sandbox mode (Read Tools allowed,
Spawn Tools blocked) — details in
trigger-actions §8:
# _vance/hooks/inbox.item.created/classify-sales-relevance.yaml
description: Classifies Inbox Items via LightLlm and tags them
script:
source: document
path: scripts/triage-inbox.js
timeoutSeconds: 15
// scripts/triage-inbox.js
const e = vance.params.event; // Lifecycle Event Payload
const verdict = vance.lightllm.call({
recipe: "inbox-triage",
vars: { title: e.item.title, body: e.item.body }
});
if (verdict.relevance === "high") {
vance.tools.invoke("inbox_tag", { itemId: e.item.id, tag: "sales-hot" });
}
4.3 Example — Workflow Ursahook
Starts a Magrathea Workflow when a Process completes:
# _vance/hooks/process.completed/post-process.yaml
description: Triggers the post-processing pipeline
workflow: post-process-pipeline
params:
sourceProcessId: "${event.process.id}"
4.4 Event Payload Binding
The UrsaHookDispatcher merges the Lifecycle Event Payload under the key
event into params. This is how the three Action variants see it:
| Action | Where the Payload appears |
|---|---|
recipe: |
Recipe params.event — available in the rendered prompt, in Engine param Pebble templates |
script: |
vance.params.event in the Script |
workflow: |
Magrathea Workflow Initial State (state.event) |
More on the Action Sandbox and the VanceScriptApi surface in
script-engine and
trigger-actions §8.
5. Trigger Path
1. Domain Service / Lifecycle Listener publishes UrsaHookFireableEvent
(Spring ApplicationEvent — see UrsaHookProcessLifecycleListener,
UrsaHookInboxLifecycleListener).
2. UrsaHookDispatcher.onHookFireable(event) — @EventListener @Async.
A separate correlation ID per Ursahook for each Event tick.
3. dispatch(event):
a. defs = hookRegistry.hooksFor(tenantId, projectId, event)
b. for each enabled doc in parallel (Bounded Pool, default 4):
- Event Log: TRIGGERED with source="hook:<event>:<name>",
payload={actionType: recipe|script|workflow, description}
- action = def.action() with inserted params.event = event.payload()
- ctx = TriggerContext(tenant, project, runAs/createdBy,
correlationId, sourceTag="hook:<event>:<name>")
- result = actionExecutorRegistry.execute(action, ctx, TriggerKind.HOOK)
- Event Log: COMPLETED (for SCHEDULED|SUCCESS) | FAILED + durationMs
+ spawnedId? + output?
No retries at the Ursahook level. If retries are needed, build them into
the Script (with its own budget within the timeout) or configure
them in the Workflow Action (Magrathea catch:).
No cross-Ursahook order. Ursahooks must not depend on each other. All matching Ursahooks run in parallel per Event.
Spawn actions are allowed. Unlike in the pre-unification spec, a recipe: Ursahook may spawn a ThinkProcess. Sandbox constraints
for script: Ursahooks (which Tools may be called) are uniformly in the TRIGGER_SCOPED mode from
trigger-actions §8.
- No WebSocket transmission. Ursahooks are not part of the client protocol.
- No Pebble templating in JS Ursahook. Only LLM Ursahooks render templates (for the prompt) — JS has the full language, that is sufficient.
7. Sandbox & Resource Limits
Ursahooks share the Action Sandbox from
trigger-actions §8. For script: Ursahooks, the
TRIGGER_SCOPED scope applies: full VanceScriptApi with Read Tools and
safe Write Tools, all @SpawnTool-marked Tools are blocked by the
ScriptToolsApi filter (process_spawn,
scheduler_*, event_*, hook_*). Spawning in Ursahooks therefore only happens
explicitly via recipe: or workflow: Action, not from
Script Action.
- GraalJS,
HostAccesswith Allow-List (@HostAccess.ExportonVanceScriptApi). IOAccess.NONE,allowNativeAccess=false,allowCreateThread=false,allowHostClassLoading=false.ResourceLimits.statementLimitfrom settingvance.hooks.statementLimit(default 200,000 — smaller than the regular Script limit, because Ursahooks should remain tight).- Wall-Clock Timeout from the Ursahook Doc (
timeout) plus optionalscript.timeoutSeconds. Hard viactx.close(true). - Fresh context per run, no cross-run leak.
LLM calls within an Ursahook Script run via
vance.lightllm.call(...) (see light-llm-service)
or are handled by the spawned Process / Workflow. There is no
hook-specific LLM runner anymore.
7.1 Per-Project Budget — v2
In v1, only the per-Ursahook wall-clock timeout applies (see §4 timeout, hard max. 30 s). There is no project-wide or per-event sum budget enforcer.
Sketch for expansion if volume demands it:
hooks.budget.perEventMs— summed wall-clock of all Ursahooks per Event tick; overflow →SKIPPED:budget-exceededfor Ursahooks not yet started.hooks.budget.perProjectPerHour— Dispatcher throttle per project; overflow →SKIPPED:rate-limited+ one collected Inbox Item per hour.
Both settings are not evaluated today — setting them changes nothing.
8. Event Log Integration
Ursahook runs use the generic Event Log from scheduler §7:
EventLogDocument {
source = "hook:<event>:<hookName>" // e.g., "hook:process.completed:notify-slack"
type ∈ { TRIGGERED, COMPLETED, FAILED }
correlationId = unique per Ursahook run ("hook_<uuid>")
payload = type-specific:
TRIGGERED → { actionType: "recipe"|"script"|"workflow",
description?: <hook-description> }
COMPLETED → { durationMs, spawnedId?, output? }
FAILED → { durationMs, outcome, error? }
}
spawnedId is set if the Action initiated a Process or
Workflow run (Recipe / Workflow variant). output
carries the structured response of a script: Ursahook or the
soft result of a Recipe Action (e.g., already_exists). Detailed schema
see trigger-actions §5.
correlationId links TRIGGERED and the terminal COMPLETED/FAILED of a run — the UI run detail can combine both lines. An explicit triggerCorrelationId field to the triggering Process/Inbox Event is not in the payload in v1 (cross-reference can be established from the Mongo index (tenantId, source, timestamp)).
Retention/TTL — Setting key hooks.eventLog.retentionDays is reserved as a convention (default suggestion 30 days), but no dedicated cleanup job for Ursahook rows runs in v1. If volume grows, a planned TTL implementation will follow (analogous to Scheduler cleanup, once one is in place there).
9. Bootstrap & Refresh
Project Bootstrap
When activating a project on a Brain Pod:
UrsaHookProjectLifecycleListenerlistens forProjectEnginesStartRequestedand callsUrsaHookService.bootstrapProject(tenantId, projectId).- The loader iterates over
UrsaHookEventNamevalues and callsdocumentService.listByPrefixCascade(tenantId, projectId, "_vance/hooks/<event>")for each Event —listByPrefixCascadeprovides exactly one level below the prefix, hence the loop. - For each Doc: Parse YAML via
UrsaHookYamlParser→ delegates Action validation toTriggerActionParser(Recipe/Script/Workflow disjunction + sub-field validation), checks Ursahook meta fields (enabled,description,timeout≤ 30 s,tags) and throwsUrsaHookParseExceptionwith clear migration hint if the oldtype: js|llmform is still present. - Successful Defs land in
UrsaHookRegistry(in-memory, project-wise atomically reset viareplace(...)). - Parse errors are logged and bootstrap continues.
UrsaHookService.reportParseErrors(...)exists as a helper for Inbox Item dispatch but is not automatically wired today — bootstrap errors only appear in the Brain Log, not in the User Inbox. Wiring will follow with the WebUI (§10).
Refresh
Three paths:
- Tool
hook_refresh— callable by the Agent (analogous toscheduler_refresh). - WebUI button in the Ursahook editor.
- Automatically after
hook_set/hook_delete— Delta refresh only for the affected<event>/<name>.
Hot-reload is delta-based, not full re-bootstrap.
10. Agent Tools & REST
Five tools in the hook toolset, implemented in v1:
| Tool | Labels | Effect |
|---|---|---|
hook_list |
read-only, hook |
All Ursahooks of the project (Event, Name, actionType, enabled, Source, last run from Event Log). |
hook_get(event, name) |
read-only, hook |
Full YAML of an Ursahook plus the shaped metadata (UrsaHookDto with the Action variant). |
hook_set(event, name, yaml) |
write, hook |
Upsert: validates YAML via UrsaHookYamlParser (Action disjunction + Event existence + timeout ceiling), creates the Ursahook or completely replaces the body (previous state is auto-archived by the Document Layer), triggers Delta Refresh. Response carries created: true|false. |
hook_delete(event, name) |
write, hook |
Trashes the Document, removes the entry from UrsaHookRegistry. |
hook_refresh |
admin, hook |
Full reload of all Ursahooks of the project; not included in the standard toolset. |
REST endpoints (all in vance-brain, Tenant Authority via RequestAuthority.enforce):
GET /brain/{tenant}/project/{project}/hooks # all Ursahooks, sorted
GET /brain/{tenant}/project/{project}/hooks/{event} # Ursahooks for an Event
GET /brain/{tenant}/project/{project}/hooks/{event}/{name} # Detail (UrsaHookDto)
PUT /brain/{tenant}/project/{project}/hooks/{event}/{name} # create/update (idempotent)
DELETE /brain/{tenant}/project/{project}/hooks/{event}/{name}
POST /brain/{tenant}/project/{project}/hooks/refresh
GET /brain/{tenant}/project/{project}/hooks/{event}/{name}/events # Event Log, paginated via `limit`
WebUI Editor — planned, not built in v1. Tooling plan (packages/vance-face/hooks.html, MPA pattern):
- List per Event with activated/deactivated toggle
- CodeMirror 6 for the YAML body
- Action Type dropdown (Recipe / Script / Workflow), analogous to Scheduler editor
- Run history per Ursahook (right panel) from the Event Log
- No live update (analogous to Scheduler — REST snapshots are sufficient)
The REST surface is ready; a UI consumer can build upon it once the web roadmap addresses it.
11. What v1 DOES NOT do
- No Inbound Ursahooks. In particular, no
tool.before/llm.before(Policy Gate). Rationale: a gate that synchronously blocks the Tool Loop is a latency and correctness risk (Ursahook crash blocks Process). If Policy Gates are introduced, they need their own spec including default allow behavior on Ursahook crash, audit trail, and separate timeout class. - No Ursahook Chains. Ursahook A does not trigger Ursahook B —
eventfiring in an Ursahook is not in the surface. Withrecipe:/workflow:Actions that can internally trigger other Lifecycle Events (e.g.,process.completedby the spawned worker), a de-facto chain does arise — but the Ursahook mechanism itself knows no cross-Ursahook order or dependency. - No Field-Level Merges between Tenant and Project Ursahooks of the same name (override is all-or-nothing).
- No Ursahook Quota Override per Ursahook. Budgets are project-wide settings, not Ursahook Doc fields.
- No Ursahook Versioning. Document Layer carries audit/history; an Ursahook has no explicit
versionsemantics. - No Sub-Selection on Event Payload. To implement “only if
process.recipe == 'analyze'”, build it into the Script body or use a pre-step in the Workflow — no declarativewhen:field in v1, as that would be a second language. - No Resource Layer Default. Bundled Ursahooks do not exist.
- No Streaming Output from the Ursahook. The return value of an Ursahook is not relevant for the triggering domain operation — the triggering service does not wait for the Ursahook.
- No Auto-Disable after Error Quorum. FAILED runs are logged in the Event Log, the Ursahook remains active. Operators can set a noisy Ursahook to
enabled: falseviahook_set(or the Document directly). Soft-disable with a threshold (e.g., 5 FAILEDs/10 min) is a v2 topic (see §12). - No Per-Event/Per-Project Budget. Only the per-Ursahook wall-clock timeout applies (§4, max. 30 s). The settings outlined in §7.1 are reserved conventions but not enforced.
No TTL cleanup for Ursahook Event Log rows.Done (2026-08): theevent_logcollection is gone; run history is in the Activity Feed, whose retention is managed byexpiresAt+ Mongo TTL (megadodo.retentionDays, default 90 days).- No Inbox Item for Bootstrap Parse Errors. Helper method exists (
UrsaHookService.reportParseErrors(...)), but is not called by the bootstrap path today — errors only appear in the Brain Log. - No WebUI Editor. REST surface is available, the editor entry (
hooks.html) will follow with the web roadmap (§10). - No Session/Knowledge Triggers.
session.suspended/resumedandinsight.saved/relation.createdare reserved as event names in the catalog, but do not fire in v1 — the respective domain services do not publish anUrsaHookFireableEventtoday (§3).
12. Open Items
- Sync Ursahooks. Do we ever need an event type that runs synchronously to the triggering operation (e.g., Ursahook MUST have run before
insight.savedcommit)? Current spec: no. If yes, this would be a new event suffix*.beforewith its own timeout/default allow semantics. - Ursahook Subscription Filter. Instead of
<event>as a path component, perhapsevents: [a, b]in the Doc plus awhen:expression in the long term. v1 sticks to one Ursahook per event path, easier to reason about. - Dead-Letter Queue. Today, a FAILED Ursahook goes into the Event Log, no retry. If use cases arise (“Slack was down for 2min, send me again”), a separate DLQ spec.
- Per-Ursahook Identity. Currently, every Ursahook runs under the
createdByof its document (orsystem:hookif the field is empty). Should an Ursahook run under an explicit user identity (analogous to SchedulerrunAs)? Relevant onceinbox.createrecipient validation applies. - HTTP Tool Security for Ursahook Scripts. Ursahooks use the standard
web_fetchtool. The old Ursahook-specific settings (vance.hooks.http.allowPrivateNetworks,vance.brain.publicHostsblock) are not evaluated in the standard tool. Backlog: Add allowlist + private network block toweb_fetchas soon as an Ursahook needs the self-hosted VPN scenario. - Auto-Disable after Error Quorum. Noisy Ursahooks should automatically switch to
enabled: falseafter, e.g., 5 FAILEDs in 10 min and send an Inbox Item tocreatedBy(see §11). Counter storage and reset strategy still open. - Bundled Convention Ursahooks. Spec currently says “no bundled Ursahooks”. If we want to deliver a small standard library (e.g., “Slack webhook from setting X”), the Resource Layer must be opened for it.
- Project Owner Resolution.
inbox.create({recipient: "owner"})is currently an alias forcreatedBy. OnceProjectDocumenthas an owner field,"owner"should switch to it — decidable with the multi-user spec update.