// automations: scheduled agent wake-ups (.intentic/config/automations.json) import { z } from "zod"; import { AgentOriginSchema, ModelPinSchema } from "./agent.js"; import { entryId } from "./internal.js"; import { IssuesConfigSchema } from "./issues.js"; import { ZoneSchema } from "../time/zone.js"; // An automation wakes the agent: the daemon fires each enabled one on its trigger, runs the optional guard command // (non-zero exit skips the wake), then runs one turn with the prompt. The manifest is user config; run history is // daemon-recorded. // schedule: fires on its cron. Moments missed while the sandbox was down fire once, late, as the daemon starts, however // many there were: a recurring wake is owed its work, not a backlog // once: fires a single time at `at`, then retires itself (the daemon switches it off as it fires). A fire missed while // the sandbox was down still lands, late, on the next poll // event: fires when an external system POSTs /automations/{id}/fire; its auth token lives outside the manifest // (.intentic/secrets/doors.json), never in this versioned, widely-readable file // listener: fires from a realtime source's own connection (an extension's gateway), no cron, no token, never reachable // via /fire. `channelId`/`eventType`/`mentioned` narrow it; absent means unfiltered. `provider`/`eventType` are open // strings validated at upsert against installed extensions' declared vocabulary // webchat: the exception with no gateway; a widget POSTs to /webchat//message, and its abuse boundary is // allowedOrigins plus a rate limit, since no secret can live in a browser // ci: the daemon's own pipeline receiver; `channelId` is the workspace repo, `branch` narrows further since a fleet // pushes one branch per agent // workspace: fires from the sandbox's own codebase (WorkspaceEventKindSchema) // What the daemon emits as the fleet works, for a code chore to react to; no token or route, since only the daemon // reads these. // turn.settled: fires once per isolated turn regardless of outcome, while the diff is still on screen // agent.landed: fires only once work reaches the main tree, including a later manual Land // deps.broken/deps.fixed: retired with the check that ran after every land (2026-09-27). Nothing emits them any more; // they stay so an automation written for them still reads, and it never fires. Main's failing CI is the `ci` listener's. export const WorkspaceEventKindSchema = z.enum(["turn.settled", "agent.landed", "deps.broken", "deps.fixed"]); export type WorkspaceEventKind = z.infer; // Payload of a workspace-triggered wake, delivered as JSON in $AUTOMATION_PAYLOAD and appended to the prompt. // `repos[].from` is each repo's state before the turn; the span runs to the working tree, not a commit, so an errored // turn's uncommitted work still shows. export const WorkspaceEventSchema = z.object({ event: WorkspaceEventKindSchema, agentId: z.string(), title: z.string().optional(), branch: z.string(), // `ready` is a clean turn held on the branch (auto-land off): the moment right before a deliberate Land, when a // pre-land review wants to run. outcome: z.enum(["landed", "conflict", "ready", "idle", "error"]), repos: z.array(z.object({ repo: z.string(), from: z.string(), dir: z.string() })), // Present only for `deps.*` events: which project broke, the command and exit code, a bounded log tail, and // `attempt` counting consecutive failures since the last pass. deps: z .object({ project: z.string(), command: z.string(), exitCode: z.number(), attempt: z.number(), logTail: z.string(), }) .optional(), }); export type WorkspaceEvent = z.infer; export const TriggerSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("schedule").describe("On a clock."), cron: z.string().min(1).describe("When, in cron notation."), // A cron is a WALL-CLOCK RULE: "43 20 * * *" is not a moment, it is 20:43 on some clock, and which clock is // not written in it. The composer sends the reader's own zone here; absent, the sandbox's `timezone` setting // answers, and absent that, UTC. Stored per automation rather than only globally so one chore can keep a // colleague's hours, or a market's, without moving everything else. tz: ZoneSchema.optional().describe( "Which clock the times in the cron mean, as a zone name like Europe/Warsaw. Leave it out to use the sandbox's own setting, which is what you want unless this one chore belongs to a different place.", ), // Fires only once at least this many other sessions have started since this automation's last wake; a due run // short of that is recorded as skipped. afterSessions: z .number() .int() .positive() .optional() .describe("Fire only once at least this many new sessions have been run since the last wake. A due run short of that is skipped, and says how far off it is."), }), z.object({ kind: z.literal("once").describe("At one moment, and then never again."), // An absolute instant, not a wall-clock time plus a zone: the composer resolves the reader's own clock at save, // so a reminder set for 3pm in Warsaw fires at that instant whatever the sandbox's own zone is. at: z .number() .int() .positive() .describe("The moment it fires, in milliseconds. An absolute instant, so it means the same thing wherever the sandbox runs."), }), z.object({ kind: z.literal("event").describe("When something calls its webhook."), // Calls per UTC day across every caller; absent falls back to FIRE_DAILY_MAX_DEFAULT, never uncapped. dailyMax: z .number() .int() .positive() .optional() .describe("How many webhook calls a day may wake the agent, across every caller. Absent is a modest default rather than unlimited."), }), z.object({ kind: z.literal("listener").describe("When a message arrives from somewhere outside."), provider: z.string().min(1).describe("Which service to listen to."), channelId: z.string().min(1).optional().describe("Narrow it to one channel or thread."), eventType: z.string().min(1).optional().describe("Narrow it to one kind of event."), mentioned: z.boolean().optional().describe("Only when the agent is actually addressed, rather than on everything said in earshot."), // ci only: the git ref the pipeline ran on. Absent ⇒ every branch of the matched repos. branch: z .string() .min(1) .optional() .describe("Narrow it to one branch, for the sources that have branches. Absent means every branch of the repositories it matches."), // Shared by webchat and issues, the two gateway-less browser sources; one field since both ask the same // question of the same header. allowedOrigins: z .array(z.string()) .optional() .describe("Which websites may reach the public endpoint, the chat widget's or the bug reporter's. Absent or empty admits nobody."), }), // `repo` narrows to events whose span touches one workspace repo ("root" or a repo id); absent ⇒ any. z.object({ kind: z.literal("workspace").describe("When something happens to the files or the repositories."), event: WorkspaceEventKindSchema.describe("Which happening."), repo: z.string().min(1).optional().describe("Narrow it to one repository. Absent means any of them."), }), ]); export type Trigger = z.infer; // A watch is an automation whose check decides, without a model, whether anything happened: either its `guard` command // or one of these ready-made sources, which the daemon checks itself (automations/watch/watch-sources.ts). Only once the check // says so does anything run, and what it saw goes with the wake. No source needs an extension, a token or a shell. // npm: the registry's view of one package. With `range`, the check passes once a published version satisfies it, and // what it saw is that version; without, what it saw is the version under `tag` (latest by default), for `fireOn: change`. // github-release: a public repository's newest release (prereleases only when asked), for `fireOn: change`. // url: one page, its text or the first `select` match, for `fireOn: change`. export const WatchSourceSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("npm").describe("A package on the npm registry."), package: z.string().min(1).max(214).describe("The package name, scoped or not, like bun or @scope/name."), range: z .string() .min(1) .max(200) .optional() .describe("A semver range, like >=1.4.3. Passes once a published version satisfies it. Prereleases count only when the range names one."), tag: z.string().min(1).max(100).optional().describe("Which dist-tag to read without a range. Absent means latest."), }), z.object({ kind: z.literal("github-release").describe("The newest release of a public GitHub repository."), repo: z .string() .regex(/^[\w.-]+\/[\w.-]+$/, "repo must look like owner/name") .describe("The repository, as owner/name."), prereleases: z.boolean().optional().describe("Count prereleases too. Absent means only full releases."), }), z.object({ kind: z.literal("url").describe("One web page."), url: z.string().url().describe("The page, http or https."), select: z .string() .min(1) .max(500) .optional() .describe("A regular expression; what it matches (its first group, if it has one) is what is watched rather than the whole page's text."), }), ]); export type WatchSource = z.infer; // What a passing check sets off. `agent` (the default) is a new agent with the prompt, as every automation always did; // `conversation` continues one that already exists, the way a watch wakes the conversation that armed it, so a plan // made weeks ago resumes where it stopped; `notify` runs no model at all, and only tells the owner what the check saw. export const AutomationTargetSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("agent").describe("A new agent, told the prompt and what the check saw.") }), z.object({ kind: z.literal("conversation").describe("An existing conversation, continued with what the check saw and the prompt."), conversationId: z.string().min(1).describe("Which conversation."), }), z.object({ kind: z.literal("notify").describe("No agent: a notification to the owner with what the check saw.") }), ]); export type AutomationTarget = z.infer; // `pass` (the default): every check that passes fires. `change`: a check fires only when what it saw differs from what // it saw last time, and the first one only records where things stand, so a watch says when something moves rather // than that it is still there. export const FireOnSchema = z.enum(["pass", "change"]); export type FireOn = z.infer; // What the daemon remembers of a watch between checks (.intentic/records/automation-watch.json), and what the list // shows under it. Values are kept whole up to a bound, so the next check can say what changed; whether anything changed // is decided on the digest of the whole value, so a page longer than the bound is not "changed" on every check. export const WatchStateSchema = z.object({ armedAt: z.number().describe("When it was first checked, in milliseconds."), checkedAt: z.number().describe("When it was last checked, in milliseconds."), value: z.string().optional().describe("What the last passing check saw, cut to a bound. Absent until one has passed."), digest: z.string().optional().describe("sha256 of the whole of what the last passing check saw, before any cut."), // A watch edited to check something else starts over: what the old check saw says nothing about the new one. condition: z.string().optional().describe("Which check this state belongs to: a digest of the guard or source it was recorded for."), changedAt: z.number().optional().describe("When what it saw last changed, in milliseconds."), firedAt: z.number().optional().describe("When it last set something off, in milliseconds."), // Why the last check did not pass, when it did not. waiting: z.string().optional().describe("What the last check said when it did not pass: what it is still waiting for."), }); export type WatchState = z.infer; // POST /automations/check: one source checked now, nothing remembered. export const WatchCheckInputSchema = z.object({ source: WatchSourceSchema.describe("The source to check.") }); export const WatchCheckResultSchema = z.object({ pass: z.boolean().describe("Whether the check passes now."), saw: z.string().describe("What it saw when it passed, or what it is still waiting for when it did not."), }); export type WatchCheckResult = z.infer; // The Visitor chat widget's settings, present only on `webchat` listener automations. Split into what the widget itself // may read (public by construction) and what only the daemon may (turnstileSecret); GET /webchat//config serves the // first group by naming it, never by omitting the second. export const WebchatConfigSchema = z.object({ // `public` admits anyone; `google` requires a verifiable Google ID token. Absent means public. access: z .enum(["public", "google"]) .optional() .describe("Who may write to it. Absent means anyone, which is the anonymous support box it looks like."), // Cosmetic: the typed name reaches the model as untrusted `displayName`, never as identity. requireName: z .boolean() .optional() .describe( "Ask a visitor for a name first. Cosmetic: the name is typed, so it reaches the model as something a stranger said, never as identity.", ), // `turnstile` needs Cloudflare's own keys; `pow` is a hashcash puzzle the daemon issues itself. Absent leaves the // origin allowlist and rate limit as the whole boundary. antiBot: z .enum(["turnstile", "pow"]) .optional() .describe( "How to keep bots out: a third-party check that needs the site's own keys, or a puzzle the sandbox sets and the widget solves, so a site with no such account still has something. Absent leaves the site allowlist and the rate limit as the whole boundary.", ), turnstileSiteKey: z.string().optional().describe("The public half of those keys, which ships to the visitor's browser."), turnstileSecret: z.string().optional().describe("The private half, which the sandbox keeps and the widget never sees."), // The site's own client id, not ours: Google only issues a token to an authorized origin, and no client can list // every customer's domain. googleClientId: z .string() .optional() .describe( "The site's own sign-in client id. It cannot be ours: a sign-in is only issued to an approved origin, and no single client can list every customer's domain.", ), // Widget chrome; `accent` must be a hex colour, not any CSS colour, since the widget derives a scheme, a button // wash and a focus ring from its channels, and an unreadable value would leave half the accent missing. title: z.string().max(80).optional(), greeting: z.string().max(500).optional(), accent: z .string() .regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, "accent must be a hex colour, e.g. #e47100") .optional(), position: z.enum(["top-right", "top-left", "bottom-right", "bottom-left"]).optional(), // Two ceilings beyond the route's per-minute window, since a public endpoint's real exposure is cost, not rate: // `dailyMessageMax` (absent falls back to WEBCHAT_DAILY_MAX_DEFAULT) caps the automation per UTC day, // `conversationMessageMax` caps one visitor thread and stays uncapped by default. dailyMessageMax: z.number().int().positive().optional(), conversationMessageMax: z.number().int().positive().optional(), // How long a visitor thread resumes the same conversation before a new message starts a fresh one; absent uses the // daemon's own default. sessionTtlMinutes: z.number().int().positive().optional(), // The AI Act (Art. 50(1)) has a person told they are talking to an AI unless it is obvious. Absent means shown, since // a one-line label costs nothing and leaving it out is the decision that needs making on purpose. aiDisclosure: z .boolean() .optional() .describe("Tell visitors, in the panel, that the replies come from an AI. Absent means on."), aiDisclosureText: z .string() .max(300) .optional() .describe("Your own wording for that line. Absent uses the built-in one in the visitor's language."), }); export type WebchatConfig = z.infer; // Default daily agent-turn ceiling for an unconfigured Visitor chat; high enough for real traffic, low enough to blunt a // script. export const WEBCHAT_DAILY_MAX_DEFAULT = 200; // The widget wire: the shapes GET /webchat//config, GET .../challenge and POST .../message speak, living beside the // config they derive from since the widget is a second client of this daemon. Imported as types only, so zod never // reaches a visitor's browser. // What the widget is told about itself, fully resolved daemon-side so it carries no fallback logic. Everything here is // public by construction: it ships to any browser reaching the endpoint from an allowed origin. export const WebchatPublicConfigSchema = z.object({ automationId: z.string(), title: z.string(), greeting: z.string(), accent: z.string(), position: z.enum(["top-right", "top-left", "bottom-right", "bottom-left"]), access: z.enum(["public", "google"]), requireName: z.boolean(), // "off" is spelled out, not left absent, so a serialization bug can't silently read as "no challenge". antiBot: z.enum(["turnstile", "pow", "off"]), turnstileSiteKey: z.string().optional(), googleClientId: z.string().optional(), aiDisclosure: z.boolean(), aiDisclosureText: z.string().optional(), }); export type WebchatPublicConfig = z.infer; // A proof-of-work challenge: find a nonce whose SHA-256 of `${salt}:${nonce}` starts with `difficulty` zero bits. One // shape for every public door (Visitor chat, bug intake), and one matching solver, embed.ts. export const PowChallengeSchema = z.object({ salt: z.string(), difficulty: z.number().int().positive() }); export type PowChallenge = z.infer; // One visitor message; `conversationId` is the widget's own localStorage id, a thread key rather than a secret — the // origin allowlist, challenge and rate limit are the gate. The polling route uses the same id schema. export const WebchatConversationIdSchema = z.string().min(1).max(200); export const WebchatMessageSchema = z.object({ conversationId: WebchatConversationIdSchema, content: z.string().min(1), // What the visitor typed as a name; never identity, tagged unverified for the model. displayName: z.string().max(200).optional(), // A Google ID token from the site's own client id, verified daemon-side against Google's JWKS. idToken: z.string().optional(), // The anti-bot answer, whichever kind the config asked for. Checked once per conversation, not per message. turnstileToken: z.string().optional(), powNonce: z.string().optional(), // Sent only on a thread's first message; after that the sandbox's own conversation carries context. history: z .array(z.object({ author: z.string().optional(), content: z.string() })) .max(50) .optional(), }); export type WebchatMessage = z.infer; // What GET /webchat//messages answers: replies queued after the visitor's stream closed, which is every reply an // approval-gated guest produces and every one a human writes as the agent. Public by construction, like the config: // reaching it needs nothing but an allowed origin and the conversation id already in that browser. export const WebchatPendingSchema = z.object({ // Oldest first, so appending them to the log preserves the order they were written in. replies: z.array(z.object({ seq: z.number(), at: z.number(), text: z.string() })), // Send back as `after` next time. Can jump past the last reply returned, when older ones were trimmed away. cursor: z.number(), }); export type WebchatPending = z.infer; // Who may talk to a listener automation, and as whom. Matched on the ids a provider vouches for (a Discord user id, a // Slack member id, a WhatsApp number, an e-mail address) or the groups it reports (Discord role ids), never on a display // name, which the sender chooses. A rule's `actsAs` means exactly what the automation's does: absent is no persona, // the full toolbox reaching no account, which is the whole agent an owner talks to themselves. export const SenderRuleSchema = z .object({ label: z.string().max(60).optional().describe("What to call these people on screen."), ids: z.array(z.string().min(1).max(200)).max(200).optional().describe("Sender ids, as the service names them, never display names."), groups: z .array(z.string().min(1).max(200)) .max(50) .optional() .describe("Group ids the service reports on a sender, a Discord role. Only for a source whose messages carry them."), actsAs: entryId.optional().describe("Which persona their wakes speak as. Absent is no persona: the full toolbox, reaching no account."), // Tightens, never loosens: the automation's own `requireApproval` still holds everyone. requireApproval: z.boolean().optional().describe("Hold their wakes for a person, even when the automation itself does not."), }) .refine((rule) => (rule.ids?.length ?? 0) + (rule.groups?.length ?? 0) > 0, { message: "a sender rule must name at least one id or group" }); export type SenderRule = z.infer; // `others` is what a sender no rule names gets: today's behaviour (`allow`), the approvals queue (`hold`), or silence // (`ignore`), which is the default the form writes since naming people is usually done to shut everyone else out. export const SendersSchema = z.object({ rules: z.array(SenderRuleSchema).max(50).describe("Walked in order; the first rule naming the sender decides."), others: z.enum(["allow", "hold", "ignore"]).describe("What a sender no rule names gets: the automation as configured, a hold for a person, or nothing at all."), }); export type Senders = z.infer; export const AutomationSchema = z.object({ id: entryId.describe("The automation's id."), trigger: TriggerSchema.describe("What sets it off: a schedule, an event in the workspace, a message arriving from outside, or a webhook."), // Runs in the workspace root before waking; exit 0 wakes, non-zero is recorded as "skipped". Sees // `AUTOMATION_PAYLOAD` when the trigger carried one. guard: z .string() .min(1) .optional() .describe( "A command run before the wake that decides whether there is anything to do. Skipped by the guard is often the most useful thing an automation can report. What it prints when it passes goes with the wake; $AUTOMATION_STATE names a file it may keep between runs, and $AUTOMATION_LAST holds what it printed last time it passed.", ), // The guard's alternative: a condition the daemon checks itself. Refused at upsert beside a guard, since two checks // would leave "what the check saw" meaning two things. source: WatchSourceSchema.optional().describe( "A ready-made check instead of a guard command: an npm package's version, a GitHub repository's newest release, or a web page.", ), fireOn: FireOnSchema.optional().describe( "Fire on every passing check, or only when what the check saw changed since the last one. Absent means every passing check.", ), // Retirement, so a watch never outlives what it was for: `first-fire` switches it off as it fires, the way a one-time // wake retires; `expiresAt` switches it off at a deadline and says so. until: z .enum(["first-fire"]) .optional() .describe("Switch it off the first time it fires, for a watch waiting on one thing. Absent means it keeps firing."), expiresAt: z .number() .int() .positive() .optional() .describe("When it gives up, in milliseconds. Switched off then, and whoever it was for is told it never fired."), target: AutomationTargetSchema.optional().describe( "What a passing check sets off: a new agent (the default), an existing conversation, or only a notification to the owner.", ), // One line a notification and a woken conversation read; an automation has no title of its own. note: z .string() .min(1) .max(200) .optional() .describe("One line on what it watches for, like 'Bun 1.4.3 is published'. Shown in its notifications."), prompt: z.string().min(1).describe("What the woken agent is told."), // The Visitor chat widget's settings, `webchat` listener automations only, ignored on every other trigger. webchat: WebchatConfigSchema.optional().describe("Settings for the public chat widget, for an automation that answers visitors."), // Bug intake settings, `issues` triggers only; its own field rather than a shared bag, since a chat's and an // intake's fields mostly don't overlap. issues: IssuesConfigSchema.optional().describe( "Settings for the bug reporter, for an automation that takes crash reports from your own sites and apps.", ), // Narrows this job's toolbox further than its persona allows; the composer enforces that it can only remove, never // restore, a shelf the persona switched off. allowedTools: z .array(z.string().min(1)) .optional() .describe( "Narrow the woken turn to these tools. For one driven by an outside message this list is the real boundary, because prompt wording is only advice and an empty toolbox is not.", ), // Ordered ladder (replaces separate agent/harness/model fields): an automation spends real money unwatched, so it must // name what it spends rather than inherit a chat's default. Walked in order at fire time to the first rung that can // start; each rung is a whole pin (provider+model+effort+harness together). Required for a new agent, the default // target, and refused there at upsert when absent; a conversation target runs on that conversation's own model and a // notification runs none, so neither needs one. models: z .array(ModelPinSchema) .min(1) .max(10) .optional() .describe( "Which models this automation may run on, best first. Required when it starts a new agent, and nothing is chosen for you: work that fires while nobody is watching spends a real allowance, so it names the models it spends rather than inheriting one. Tried in order, so a spent account does not silently stop the job. Not needed to continue a conversation, which runs on its own model, or to only notify.", ), // Absent means the daemon's pick: the account its conversation runs on for a continuing thread (moved off one that // can no longer serve), else the one that can serve with the most room. Named, it is a pin, run whatever its state, // so a refused account says so rather than being swapped; pinning matters more here than for a chat, since nobody // is watching to notice a stuck one. account: z.string().optional().describe("Which account pays for it."), // Absent means the wake reaches no logged-in account at all, the strictest default here, since nobody is at the // composer when it fires. actsAs: entryId.optional().describe("Which persona it speaks as. An unwatched turn naming none reaches no signed-in account at all."), // Listener triggers on a source that identifies senders only (TriggerSource.sender); refused elsewhere at upsert. // Absent means everyone the filters admit, wearing `actsAs`. senders: SendersSchema.optional().describe( "Who may talk to it, and as whom: rules by sender id or group, each naming the persona those people get, plus what everyone else gets. Absent admits everyone the trigger's filters do.", ), // Held in the approvals queue rather than run; only a person releases it. requireApproval: z.boolean().optional().describe("Hold every fire for a person instead of running it. Only a person can release one of those."), // Held visibly in the approvals queue and run by the daemon itself once the hold elapses with no live turn; // `requireApproval`, if also set, always wins. holdForSeconds: z.number().optional().describe("Hold each fire this long before running it anyway, which is a delay rather than a decision."), // Pure classification: the trigger alone can't tell a code chore from an ordinary schedule, so this is stored // rather than derived. chore: z .boolean() .optional() .describe("This automation is a maintenance job, which is what files it under chores rather than among ordinary automations."), enabled: z.boolean().describe("Whether it fires at all."), }); export type Automation = z.infer; // A wake held for owner approval (automation-holds/.json on the history volume); snapshots the trigger payload so an approved // run replays exactly what fired, even across a daemon restart. export const AutomationApprovalSchema = z.object({ id: entryId.describe("This waiting item's own id, which approving and rejecting take."), automationId: z.string().describe("Which automation it came from."), // The event/listener payload the wake would have carried; absent for schedule triggers. payload: z .string() .optional() .describe( "What set it off, kept whole so an approved wake carries the same thing it would have had. Absent for one on a schedule, which carries nothing.", ), // Snapshotted alongside the payload so an approved wake surfaces on the fleet exactly as an automatic one would. origin: AgentOriginSchema.optional().describe( "Where the message came from, kept alongside the payload so an approved wake appears on the board exactly as an automatic one would have.", ), title: z.string().optional().describe("What the conversation would be called."), // What the automation's check saw when it passed, so the approved wake carries it as an automatic one would have. observed: z.string().optional().describe("What the automation's check saw when it passed, kept so the approved wake is told the same thing."), // The thread this wake belongs to, so approving continues it rather than minting a fresh conversation per approved // message. conversationId: z .string() .optional() .describe( "The thread this belongs to, when it has one, so approving continues that conversation rather than opening a new one. Without it, one visitor's chat becomes a card per approved message and an agent that meets them again every turn.", ), sessionId: z.string().optional().describe("The provider session that thread last ran on."), // The thread-sessions key the message arrived on, so the approved run settles the same thread; not derivable from // `origin`, since a thread is also keyed by the persona the sender's lane resolved to. thread: z.string().optional().describe("Which inbound thread this belongs to, so the approved run continues that thread's memory rather than a fresh one."), // The persona the fire resolved to (a sender rule's, or the automation's), snapshotted at hold time and worn by the // approved run; also what a project-scoped board files the wake under. actsAs: entryId.optional().describe("Which persona the approved run speaks as, decided when it was held."), createdAt: z.number().describe("When it started waiting, in milliseconds."), // When the daemon may run this itself, for a `holdForSeconds` hold; absent for a `requireApproval` hold, which only // the owner releases. autoRunAt: z .number() .optional() .describe( "When it goes ahead on its own, in milliseconds, for a hold that is only a delay. Absent for one that genuinely waits on a person, and for one the sandbox did not itself queue, which only a person releases.", ), }); export type AutomationApproval = z.infer; // Whether a held wake waits on a person. A countdown hold (`autoRunAt`) releases itself, so counting it would ask the // owner about something already about to happen on its own. export const awaitsOwner = (wake: Pick): boolean => wake.autoRunAt === undefined; export const AutomationApprovalsListSchema = z.object({ approvals: z.array(AutomationApprovalSchema).describe("Everything waiting for a yes."), // Reported, never dropped: a file in the queue the sandbox cannot read would otherwise be a hold that never runs and never says why. unreadable: z .array(z.string()) .default([]) .describe("Files in the queue this sandbox could not read, by name. Each is a held wake that cannot be released until it is fixed or removed."), }); export const AutomationApprovalIdParamSchema = z.object({ id: z.string().describe("Which waiting item.") }); export const AutomationRunSchema = z.object({ at: z.number(), // skipped: the guard said no // error: the guard passed but the turn errored // interrupted: the daemon died mid-wake; without this value an interrupted fire vanishes from history and reads as // never having fired outcome: z.enum(["completed", "skipped", "error", "interrupted"]), detail: z.string().optional(), // Absent only for a run skipped before a conversation was needed. conversationId: z.string().optional(), }); export type AutomationRun = z.infer; // The list row: the stored automation plus its recent runs and next scheduled fire (absent when disabled). Default // daily ceiling for an event trigger naming none: generous for a busy monitor, small enough that a leak is a bad day, // not a bad month. export const FIRE_DAILY_MAX_DEFAULT = 200; export const AutomationSummarySchema = AutomationSchema.extend({ runs: z.array(AutomationRunSchema), nextRun: z.number().optional(), // Absent for an automation with no check, and for one never checked. watch: WatchStateSchema.optional().describe("What its check saw last and when, for an automation that checks something before it fires."), // Door credentials, attached for a maintainer or owner only, never a viewer or a control-token program; kept in the // secrets store, not the manifest. webhookToken: z .string() .optional() .describe("What a caller presents at /automations/{id}/fire, for an event automation. Shown to a maintainer or the owner only."), ingestKey: z .string() .optional() .describe("What a client with no website origin presents to a bug intake. Shown to a maintainer or the owner only."), }); export type AutomationSummary = z.infer; export const AutomationsListSchema = z.object({ automations: z.array(AutomationSummarySchema) }); // One person a listener source has heard from (.intentic/records/senders.json), kept per provider so the form can offer // "seen recently" by name and store the id. Recorded whether or not a rule admitted them: who tried is the point. export const SenderSeenSchema = z.object({ id: z.string().describe("The sender id the service vouches for, what a rule stores."), name: z.string().describe("What they were called on their last message, for display only."), groups: z.array(z.string()).optional().describe("The group ids the service reported on their last message, a Discord role list."), firstSeenAt: z.number().describe("When they first reached an automation here, in milliseconds."), lastSeenAt: z.number().describe("When they last did, in milliseconds."), messages: z.number().describe("How many of their messages reached an automation's filters, admitted or not."), }); export type SenderSeen = z.infer; export const SendersRosterSchema = z.object({ senders: z.array(SenderSeenSchema).describe("Newest first.") }); export const SendersProviderParamSchema = z.object({ provider: z.string().min(1).describe("Which listener source.") }); export const AutomationIdParamSchema = z.object({ id: z.string() }); export const AutomationEnabledInputSchema = z.object({ id: z.string(), enabled: z.boolean() }); // The automation catalogue: everything that can wake an agent, and what to start from. The daemon merges what it emits // with what every installed extension declares, so an area gains a trigger by declaring it rather than editing this // surface. `webchat`/`ci` are the daemon's own sources; a template sits beside the source whose payload it describes, // or beside its capability's pack if it fires on the generic `event` webhook. // Absent means the editor offers no such filter, rather than inventing one meaningless to the provider. const TriggerFieldSchema = z.object({ label: z.string().min(1), placeholder: z.string().min(1), hint: z.string().min(1).optional() }); export const TriggerSourceSchema = z.object({ // The slug a listener trigger fires on (Trigger.provider). provider: z.string().min(1), label: z.string().min(1), // Simple-icons slug, or an app glyph, the same logo/icon split a capability card and an extension mark draw. logo: z.string().min(1).optional(), icon: z.string().min(1).optional(), events: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })), channel: TriggerFieldSchema, // A SECOND narrowing axis, for sources whose events carry one, `ci` narrows by git ref as well as by repo. branchField: TriggerFieldSchema.optional(), // How this source names a sender (`author.id`) and, where its messages carry them, a sender's groups; absent means // the source vouches for nobody's identity, so the editor offers no sender rules and upsert refuses them. sender: TriggerFieldSchema.optional(), senderGroup: TriggerFieldSchema.optional(), // Only sources whose `message` events distinguish addressed messages set this; absent ⇒ no mention-only filter. mentionLabel: z.string().min(1).optional(), // The provider owns the payload vocabulary, so it owns the first prompt that explains that payload. starterPrompt: z.string().min(1).optional(), // Any one connected capability is enough; computed client-side, since capability facts are pushed live and a served // boolean would go stale between polls. requires: z.array(z.string().min(1)).default([]), // Whether the declaring extension is on; disabled ones stay listed so a stored automation using it stays readable // and editable. enabled: z.boolean(), }); export type TriggerSource = z.infer; // Absent means it lives in the create dialog's gallery. The two named forms are for what a user wouldn't think to look // for: // create: a shelf card that makes the automation in one click, switched off, ready to read (every chore is this) // configure: a shelf card that opens the dialog prefilled, for a template that can't work unconfigured export const TemplateOfferSchema = z.enum(["create", "configure"]); export const AutomationTemplateSchema = z.object({ // Prefills the automation name, so it is also what "does one of these already exist" is asked by. id: z.string().min(1), title: z.string().min(1), logo: z.string().min(1).optional(), icon: z.string().min(1).optional(), // Same rule as a source's: any one connected is enough, empty ⇒ always offered. requires: z.array(z.string().min(1)).default([]), trigger: TriggerSchema, // Prefills the guard command (a shell one-liner; non-zero exit skips the wake). guard: z.string().min(1).optional(), // Prefill a watch: a ready-made check, when it fires, whether it retires, and what it sets off. source: WatchSourceSchema.optional(), fireOn: FireOnSchema.optional(), until: z.enum(["first-fire"]).optional(), target: AutomationTargetSchema.optional(), // Prefills the countdown hold: each fire waits this long, visibly and cancellably, before starting itself. holdForSeconds: z.number().int().positive().optional(), prompt: z.string().min(1), // The card's disclosure under the title, "instant", "checks every 5 min", "skips changes under 20 lines". note: z.string().min(1).optional(), // Post-save instructions: where to paste the webhook URL this automation just minted. setup: z.string().min(1).optional(), // What the shelf card says under the title. Only offered templates need one; a gallery entry has its // trigger beside it for context. description: z.string().min(1).optional(), offer: TemplateOfferSchema.optional(), // Whether the created automation watches this codebase, carried since the trigger alone can't distinguish a chore // from an ordinary schedule. chore: z.boolean().optional(), }); export type AutomationTemplate = z.infer; export const AutomationCatalogSchema = z.object({ sources: z.array(TriggerSourceSchema), templates: z.array(AutomationTemplateSchema), }); export type AutomationCatalog = z.infer;