Connect & automate / Playbooks, schedules & sleep

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.

agents/support/urgent-tickets.tstypescript
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" },
});
agents/support/actions/triage.action.tstypescript
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.",
});
FieldRules
nameRequired, unique per agent definition. The stored id is derived from definition, instance and name (playbook- + hash).
transmissionRequired. Must have an inbound contract and be owned by an app the instance installed.
enabledDefault true. false never matches.
match.eventAn event declared in the transmission’s events. Omit to match every event.
match.routesInbound routes of this transmission. Omit to match every route.
match.predicate{ definition, parameters? }. The definition must be listed in the transmission’s inbound.predicates.
directivesAt least one { action, instruction, parameters? }. action is a definePlaybookAction label; nothing executes. All three are rendered into the agent’s turn.
applicationsApps 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.
serializationFree-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

agents/support/agent.tstypescript
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 (the travel-concierge template does the same).
  • A new instance has no definition playbooks until its first run. Until then inbound events pass it by.
  • If defineAgent has no playbooks field, 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:

scripts/refund-playbook.tstypescript
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.

agents/support/subscriptions/urgent-swarm.subscription.tstypescript
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:

foundry.application.tstypescript
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 },
        },
      ]);
    },
  },
});
provisioningWhich 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 known provisioningKey. Built-in keys look like subscription: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 context and installations, 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:

scripts/after-hours.tstypescript
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.

agents/support/schedules/weekday-digest.tstypescript
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" },
});
FieldMeaning
nameRequired, unique within one agent definition. Not a runtime id.
messageRequired. The user turn of each scheduled run.
timingOne of the four kinds below.
payloadOptional JSON passed as the run’s payload.
enabledDefault true. false cancels the activation.
descriptionOptional, for people.
timingExampleStored 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 with glove_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.
  • after counts 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 fieldValue
idDerived for definition schedules; the tool’s commandId for agent-created ones.
kind"scheduled" or "sleep"
definitionId, agentId, conversationId, workspaceIdWhere 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.
statuspending | active | paused | completed | cancelled
createdByRunId, lastRunId?, createdAt, updatedAtProvenance.

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.

ToolInputWhat it does
glove_foundry_spawnmessage, 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_schedulemessage, 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_schedulesaction: 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_backgroundmessage, 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_transmitrouteId, payloadDeliver through an outbound route this run is authorized for (see outbound) and return the adapter’s validated output.
glove_foundry_conversationsnoneList this instance’s conversations.
glove_foundry_workspaceaction: get (key), set (key, value), listRead and write the workspace’s shared key-value entries.
glove_foundry_shared_inboxaction: list, post (topic, payload, agentId?, conversationId?), update (itemId, status: pending | resolved | dismissed)The workspace inbox other agents and conversations read.
glove_foundry_tasksaction: list, create (title, detail?), update (taskId, status: open | in-progress | completed | cancelled)Workspace tasks; created tasks belong to this conversation.
glove_foundry_environmentnoneList 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?

ToolStartsRuns asUse it to
glove_foundry_schedulelater, once or repeatingthis instance and conversation by default, or another targetset reminders and recurring jobs the agent can list, edit, pause and cancel.
glove_foundry_sleeplater, oncealways this instance and conversationwait for a deploy, a cooldown or a deadline, then continue. Not listed by glove_foundry_schedules.
glove_foundry_spawnnowa new instance and conversation unless ids are givenhand work to a fresh agent or another definition.
glove_foundry_backgroundnowa second run on this instance and conversationrun 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.putActivation before the execution backend is armed.
  • On start, Foundry reloads every activation and re-arms the pending and active ones. paused, completed and cancelled stay 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 when defineApplication has no data, loses all of it on restart. Use it for development only; use FileFoundryDataAdapter({ file }) on one host or implement FoundryDataAdapter for a database. See Persistence.
  • Routes and bindings are not in the data adapter; they live in the topology store (see topology).