Playbooks, schedules & sleep
Make agents act without being asked. A playbook wakes an instance when a matching event arrives. A subscription can create the agent first. A schedule wakes it at 9am every weekday, and an agent can put itself to sleep for 20 minutes or hand work to another run. All of it is persisted through your data adapter.
Playbooks
A playbook says: when this kind of event arrives on these routes and passes this predicate, wake this instance with these instructions, and let it reply through these routes. It is plain JSON data with no functions, so it can be stored on an instance, edited by a UI or attached by a subscription. The code that runs (authentication, normalization, predicates, delivery) stays on the transmission. This page continues the agents/support example from Apps & transmissions.
import { composePlaybook } from "glove-foundry";
import triage from "./actions/triage.action.js";
import helpdesk from "./apps/helpdesk.app.js";
import ticketCreated from "./events/ticket-created.event.js";
import ticketReplied from "./events/ticket-replied.event.js";
import priorityAtLeast from "./predicates/priority-at-least.predicate.js";
import { acmeHelpdesk, ticketsInbound, ticketsOutbound } from "./topology.js";
import tickets from "./transmissions/tickets.transmission.js";
export const urgentTickets = composePlaybook({
name: "urgent-tickets",
enabled: true,
transmission: tickets,
match: {
event: ticketCreated,
routes: [ticketsInbound],
predicate: { definition: priorityAtLeast, parameters: { minimum: 3 } },
},
directives: [
{
action: triage,
instruction: "Work out what the customer needs, then reply on the ticket.",
parameters: { tone: "warm", maxSentences: 5 },
},
],
applications: [helpdesk],
outbound: [
{
route: ticketsOutbound,
application: helpdesk,
account: acmeHelpdesk,
applicationAccount: acmeHelpdesk,
event: ticketReplied,
instruction: "Reply on the same ticket.",
},
],
serialization: { payload: "json" },
});import { definePlaybookAction } from "glove-foundry";
/** A named verb. Its id ("triage") comes from the filename and is rendered into the turn. */
export default definePlaybookAction({
description: "Work out what the customer needs and answer on the ticket.",
});| Field | Rules |
|---|---|
name | Required, unique per agent definition. The stored id is derived from definition, instance and name (playbook- + hash). |
transmission | Required. Must have an inbound contract and be owned by an app the instance installed. |
enabled | Default true. false never matches. |
match.event | An event declared in the transmission’s events. Omit to match every event. |
match.routes | Inbound routes of this transmission. Omit to match every route. |
match.predicate | { definition, parameters? }. The definition must be listed in the transmission’s inbound.predicates. |
directives | At least one { action, instruction, parameters? }. action is a definePlaybookAction label; nothing executes. All three are rendered into the agent’s turn. |
applications | Apps this playbook relies on. Each must be installed. Their send tools are offered the outbound entries that name no application. |
outbound | { route, application?, account?, applicationAccount?, event?, instruction? }. route must be outbound; account must equal the route’s account; application must be installed and own the route’s transmission; event must be declared by the transmission. |
serialization | Free-form JSON. The default XML renderer includes it as a <serialization> element; a custom inbound.serialize reads it from context.playbooks. |
composePlaybook throws on a missing name, instruction or directive, an event the transmission does not declare, or parameters/serialization that are not plain JSON (functions, undefined, class instances, non-finite numbers, cycles). The rest is checked when the playbook is saved on an instance. Listing an outbound route tells the agent where to reply and narrows the send tool’s routeId; it does not authorize delivery. A binding does.
Playbooks from the agent definition
import { composeAgent, defineAgent } from "glove-foundry";
import helpdesk from "./apps/helpdesk.app.js";
import weekdayDigest from "./schedules/weekday-digest.js";
import { urgentTickets } from "./urgent-tickets.js";
export default defineAgent({
description: "Answers help-desk tickets",
components: composeAgent(helpdesk),
// model, systemPrompt, accountSessions: see Apps & transmissions
// Only valid where helpdesk is installed, so decide per instance.
playbooks: (_agent, { installations }) =>
installations.some((item) => item.kind === "application" && item.id === helpdesk.id)
? [urgentTickets]
: [],
// See "Schedules" below. An instance can opt out through its context.
schedules: (_agent, { agentInstance }) =>
agentInstance.context.digest === false ? [] : [weekdayDigest],
});- The resolver runs each time an instance is assembled for a run. Foundry writes the result onto the instance with
origin: "agent-definition", keeps a stored playbook whose content is unchanged, and removes definition playbooks the resolver stops returning. - A playbook referencing an app the instance has not installed fails validation, which is why the resolver checks
installations(thetravel-conciergetemplate does the same). - A new instance has no definition playbooks until its first run. Until then inbound events pass it by.
- If
defineAgenthas noplaybooksfield, Foundry never changes the instance’s playbooks.
Playbooks as instance data
A product UI can author playbooks per instance through the API. At that boundary definitions are referenced by their string ids:
import { createFoundryClient } from "glove-foundry/client";
const foundry = createFoundryClient({ baseUrl: "http://127.0.0.1:4141" });
await foundry.setAgentPlaybooks("support-desk", [
{
id: "refund-requests",
transmissionId: "tickets",
match: {
event: "ticket-created",
routeIds: ["tickets-inbound"],
predicate: { name: "priority-at-least", parameters: { minimum: 1 } },
},
directives: [
{ action: "triage", instruction: "If the customer asks for a refund, check the order and reply." },
],
applications: ["helpdesk"],
outbound: [{ routeId: "tickets-outbound", applicationId: "helpdesk" }],
},
]);setAgentPlaybooks (PUT /api/agent-instances/:id/playbooks, body { playbooks }) validates and replaces the stored list. Playbooks without origin: "agent-definition" survive later definition syncs; definition playbooks you leave out come back on the next run. You can also pass playbooks to createAgent, configureAgent or defineAgentInstance. The value form renames each reference: transmission → transmissionId, routes → routeIds, predicate.definition → predicate.name, route → routeId, application → applicationId, account → accountId, applicationAccount → applicationAccountId; events, actions and apps become their ids. In code, the glove-foundry/eslint preset rejects these string forms inside composePlaybook.
Subscriptions: provision agents on demand
A playbook on an instance only wakes that instance. A subscription is a playbook plus targets: when an event matches, Foundry finds or creates the target instances, gives each the playbook and installations, opens the thread’s conversation and starts one run per instance. One urgent ticket can create a dedicated agent for that ticket.
import { definePlaybookSubscription, install } from "glove-foundry";
import support from "../agent.js";
import triage from "../actions/triage.action.js";
import helpdesk from "../apps/helpdesk.app.js";
import ticketCreated from "../events/ticket-created.event.js";
import priorityAtLeast from "../predicates/priority-at-least.predicate.js";
import { acmeHelpdesk, ticketsInbound, ticketsOutbound } from "../topology.js";
import tickets from "../transmissions/tickets.transmission.js";
export default definePlaybookSubscription({
workspaceId: "acme",
playbook: {
transmission: tickets,
match: {
event: ticketCreated,
routes: [ticketsInbound],
predicate: { definition: priorityAtLeast, parameters: { minimum: 4 } },
},
directives: [{ action: triage, instruction: "Urgent ticket: own it end to end." }],
applications: [helpdesk],
outbound: [{ route: ticketsOutbound, application: helpdesk }],
},
targets: [
{
agent: support,
provisioning: { mode: "per-thread" }, // one support agent per ticket
context: { tier: "urgent" },
installations: [install(helpdesk, { queue: "support" }, { account: acmeHelpdesk })],
},
],
});The id comes from the filename (urgent-swarm), and the playbook takes the same id. Defaults: workspaceId: "default", enabled: true, provisioning singleton. Register it by seeding the data adapter, which also accepts a custom provisioner:
import { Effect } from "effect";
import { MemoryFoundryDataAdapter, defineApplication } from "glove-foundry";
import urgentSwarm from "./agents/support/subscriptions/urgent-swarm.subscription.js";
export default defineApplication({
name: "Acme Support",
data: new MemoryFoundryDataAdapter({ subscriptions: [urgentSwarm] }),
// Used by targets with provisioning: { mode: "custom", adapter: "per-requester" }.
provisioner: {
identifier: "acme-provisioner",
provision: (adapter, ctx) => {
if (adapter !== "per-requester") return Effect.succeed([]);
const requester = (ctx.event as { readonly requester: string }).requester;
return Effect.succeed([
{
provisioningKey: `requester:${ctx.subscription.workspaceId}:${requester}`,
context: { requester },
},
]);
},
},
});provisioning | Which instances receive the event |
|---|---|
{ mode: "singleton", key? } | One shared instance per subscription target (and key). |
{ mode: "per-thread" } | One instance per route and threadKey. |
{ mode: "per-event" } | A new instance per route and eventId. |
{ mode: "existing", agentIds } | Those instances. The playbook and target installations are added to them. |
{ mode: "custom", adapter } | provisioner.provision(adapter, ctx) on defineApplication. ctx: subscription, target, route, eventId, eventName, threadKey, normalized event. Return { provisioningKey, id?, workspaceId?, context?, installations?, playbooks? } seeds; missing fields come from the target. |
- Provisioning goes through
FoundryDataAdapter.provisionAgent, which returns the existing instance for a knownprovisioningKey. Built-in keys look likesubscription:acme:urgent-swarm:0:per-thread:tickets-inbound:T-1001, so concurrent events for one ticket never create two agents. Durable adapters must enforce the key atomically. - Each new instance gets the subscription’s workspace, the target’s
contextandinstallations, and the playbook. Because the subscription carries the playbook, it reacts to the very first event. - An instance matched both by its own playbook and by a subscription gets one run with both playbooks.
- Saving validates targets: the definition must exist, installations must be in its
components, and the playbook must be valid for those installations. - The whole dispatch sits behind the
<routeId>:<eventId>delivery claim, so a retried event never provisions twice.
At runtime, list, upsert and delete with client.playbookSubscriptions(workspaceId?), client.putPlaybookSubscription(subscription) and client.deletePlaybookSubscription(id) (GET/PUT /api/playbook-subscriptions, DELETE /api/playbook-subscriptions/:id). Over the API the subscription is plain data, with definitionId in each target:
import { createFoundryClient } from "glove-foundry/client";
const foundry = createFoundryClient({ baseUrl: "http://127.0.0.1:4141" });
const now = new Date().toISOString();
await foundry.putPlaybookSubscription({
id: "after-hours",
workspaceId: "acme",
enabled: true,
playbook: {
id: "after-hours",
transmissionId: "tickets",
match: { event: "ticket-created", routeIds: ["tickets-inbound"] },
directives: [{ action: "triage", instruction: "Acknowledge the ticket and promise a reply by 9am." }],
applications: ["helpdesk"],
outbound: [{ routeId: "tickets-outbound", applicationId: "helpdesk" }],
},
targets: [
{
definitionId: "support",
provisioning: { mode: "singleton", key: "after-hours" },
context: { shift: "night" },
installations: [
{ kind: "application", id: "helpdesk", accountId: "acme-helpdesk", config: { queue: "support" } },
],
},
],
createdAt: now,
updatedAt: now,
});When you embed the runtime, runtime.putPlaybookSubscription(...) accepts either this data form or the definePlaybookSubscription options.
Schedules
Give an agent recurring or future work in its definition. Agents never call setTimeout; Foundry persists an activation and starts a run when it is due.
import { defineSchedule } from "glove-foundry";
export default defineSchedule({
name: "weekday-digest",
description: "Summarise open tickets every weekday morning.",
timing: { kind: "cron", expression: "45 8 * * 1-5", timezone: "Europe/London" },
message: "List tickets still waiting on us and post a digest to the shared inbox.",
payload: { queue: "support" },
});| Field | Meaning |
|---|---|
name | Required, unique within one agent definition. Not a runtime id. |
message | Required. The user turn of each scheduled run. |
timing | One of the four kinds below. |
payload | Optional JSON passed as the run’s payload. |
enabled | Default true. false cancels the activation. |
description | Optional, for people. |
timing | Example | Stored as |
|---|---|---|
{ kind: "at", at } | { kind: "at", at: "2026-12-01T09:00:00Z" } | one-shot at that instant |
{ kind: "after", duration } | { kind: "after", duration: "2h" } | one-shot at = now + duration |
{ kind: "every", interval } | { kind: "every", interval: "30m" } | { kind: "every", intervalMs } |
{ kind: "cron", expression, timezone? } | { kind: "cron", expression: "0 9 * * 1-5" } | five-field cron, minute resolution, IANA timezone (default UTC) |
A schedule exists only if an agent’s schedules resolver returns it (see the agent.ts above). There is no schedule file convention and no global registry. How the resolver’s output is reconciled:
- It runs on every assembly of an instance. A new instance’s schedules are armed after its first run, and fire into the conversation of the run that first loaded them.
- The activation id is derived from definition, instance and schedule name, so each instance has one activation per name.
- Unchanged definition (same
message,payload,timing,enabled): the stored activation is kept, including edits and pauses made withglove_foundry_schedules. - Changed definition: the activation is replaced and re-armed, discarding runtime edits and pause state.
- Dropped from the resolver result, or
enabled: false: cancelled. Cancellation is final for that instance and name; returning the schedule later does not re-arm it, so rename it to start over. aftercounts from the assembly that first loads the schedule.
One-shot activations go pending → active → completed (or cancelled if their run was cancelled); one that came due while the runtime was down fires as soon as it starts. Recurring activations stay active, skip a tick while the previous scheduled run is still going, and collapse ticks that are more than 60 s late into a single firing. Every firing is a normal run with source: { kind: "activation", id }.
See every activation on the inspector’s Automations page (scheduled wake-ups, playbook listeners and inbound connections), or read them with GET /api/activations?workspace=acme / client.activations("acme"). These are read-only: there is no HTTP endpoint to pause or cancel an activation. The owning agent manages it with glove_foundry_schedules, or you change the definition.
| Activation field | Value |
|---|---|
id | Derived for definition schedules; the tool’s commandId for agent-created ones. |
kind | "scheduled" or "sleep" |
definitionId, agentId, conversationId, workspaceId | Where the run will happen. |
message, payload? | The run’s input. |
timing | { kind: "at", at } | { kind: "every", intervalMs } | { kind: "cron", expression, timezone } |
origin | "agent-definition" or "agent-tool"; plus scheduleName and definitionRevision for the former. |
status | pending | active | paused | completed | cancelled |
createdByRunId, lastRunId?, createdAt, updatedAt | Provenance. |
Built-in agent tools
Every Foundry agent gets these tools on every run, before your own tools; nothing needs to be installed. Tools that start or schedule work return immediately with { commandId, accepted: true, type } (plus timing or wakeAt); the parent runtime carries the command out.
| Tool | Input | What it does |
|---|---|---|
glove_foundry_spawn | message, payload?, definitionId?, agentId?, conversationId? | Start a run now. Defaults to this definition; without agentId it creates a new instance (no installations), without conversationId a new conversation. |
glove_foundry_schedule | message, timing, payload?, definitionId?, agentId?, conversationId? | Create a future or recurring activation. With no ids it targets this instance and conversation. timing as in the table above; at must be an ISO datetime ending in Z. The returned commandId is the activation id. |
glove_foundry_schedules | action: list (status?: active default, paused, completed, cancelled, all), update (activationId, message?, payload?, timing?), pause, resume, cancel (activationId) | Manage this instance’s scheduled activations, both definition and tool-created. Other instances’ schedules are invisible. Updating a paused one keeps it paused; resume works only from paused; completed or cancelled ones cannot be paused. |
glove_foundry_sleep | { kind: "for", duration, message? } or { kind: "until", at, message? } | Persist a wake-up for this instance and conversation. message defaults to “Continue the suspended work.” |
glove_foundry_background | message, payload?, definitionId?, reconvene? (default true) | Queue a separate run now on this instance and conversation. With reconvene, its finished run is posted to the shared inbox under topic background.reconvene. |
glove_foundry_transmit | routeId, payload | Deliver through an outbound route this run is authorized for (see outbound) and return the adapter’s validated output. |
glove_foundry_conversations | none | List this instance’s conversations. |
glove_foundry_workspace | action: get (key), set (key, value), list | Read and write the workspace’s shared key-value entries. |
glove_foundry_shared_inbox | action: list, post (topic, payload, agentId?, conversationId?), update (itemId, status: pending | resolved | dismissed) | The workspace inbox other agents and conversations read. |
glove_foundry_tasks | action: list, create (title, detail?), update (taskId, status: open | in-progress | completed | cancelled) | Workspace tasks; created tasks belong to this conversation. |
glove_foundry_environment | none | List non-secret environment metadata visible to this workspace, instance and conversation. |
Installed apps add one glove_app_<app>__<transmission>_send tool per outbound transmission. Workspace, inbox and tasks are described in Conversations & shared work.
Durations are either compact, an integer plus ms, s, m, h, d or w (30s, 20m, 2h, 1d), or Effect form, a number, a space and millis, seconds, minutes, hours, days or weeks, singular or plural (20 minutes, 1.5 hours). They must be greater than zero; 20 min is rejected.
Schedule, sleep, spawn or background?
| Tool | Starts | Runs as | Use it to |
|---|---|---|---|
glove_foundry_schedule | later, once or repeating | this instance and conversation by default, or another target | set reminders and recurring jobs the agent can list, edit, pause and cancel. |
glove_foundry_sleep | later, once | always this instance and conversation | wait for a deploy, a cooldown or a deadline, then continue. Not listed by glove_foundry_schedules. |
glove_foundry_spawn | now | a new instance and conversation unless ids are given | hand work to a fresh agent or another definition. |
glove_foundry_background | now | a second run on this instance and conversation | run a side task and collect its result from the shared inbox. |
Sleep does not block: the tool returns wakeAt and the current run ends when the model finishes its turn. At wakeAt a new run starts in the same conversation with the full history and the sleep message, so it reads as a continuation. A scheduled activation is a separate future run you can manage.
Durability
- Schedule and sleep activations are written through
FoundryDataAdapter.putActivationbefore the execution backend is armed. - On start, Foundry reloads every activation and re-arms the
pendingandactiveones.paused,completedandcancelledstay disarmed, so a paused schedule is still paused after a restart. - Instances (with their playbooks and installations), subscriptions and inbound delivery claims live in the same adapter.
MemoryFoundryDataAdapter, the default whendefineApplicationhas nodata, loses all of it on restart. Use it for development only; useFileFoundryDataAdapter({ file })on one host or implementFoundryDataAdapterfor a database. See Persistence.- Routes and bindings are not in the data adapter; they live in the topology store (see topology).