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. 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 Think Process. 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 per 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 Enum UrsaHookEventName 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} |
as above |
inbox.item.created |
active | InboxItemCreatedEvent (every InboxItemService.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 — as above for SessionService.resume(...). |
— | event.session.{id, name, projectId} (planned) |
insight.saved |
reserved — Knowledge Graph Service Layer does not exist yet. | — | event.insight.{name, title, body, scope, tags, sourceProcessId} (planned) |
relation.created |
reserved — 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, which 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. 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 concrete 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 |
only recipe: |
First USER message for the spawned Think Process |
runAs |
String |
no | User identity. Default: createdByUserId of the Ursahook Document |
enabled |
boolean |
default true |
Pause without deleting |
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.
Each Event tick has its own correlation ID per Ursahook.
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 Think Process. Sandbox constraints
for script:-Ursahooks (which Tools may be called) are uniformly in the TRIGGER_SCOPED mode from
trigger-actions §8.
- No WebSocket sending. 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_create, process_run,
scheduler_*, event_*, hook_*). Spawning in Ursahooks therefore only happens
explicitly via recipe:- or workflow:-Action, not from
a 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 extension 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. - Per 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 |
Complete 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 come, 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 —
event-firing in an Ursahook is not on 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. The Document Layer carries audit/history; an Ursahook has no explicit
versionsemantics. - No Sub-Selection on Event Payload. To “only if
process.recipe == 'analyze'”, build that 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 land 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 sketched in §7.1 are reserved conventions, but not enforced.
- No TTL Cleanup for Ursahook Event Log Rows.
hooks.eventLog.retentionDaysis a reserved key — without a dedicated pruning job, rows inevent_logwill grow until a cleanup comes. - 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. The REST surface is available, the MPA editor (
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 Issues
- 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 again”), 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.publicHosts-block) are not evaluated in the standard tool. Backlog: Port 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 go 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 do 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 tocreatedBy. As soon asProjectDocumenthas an Owner field,"owner"should switch to it — decidable with the Multi-User Spec Update.