Connect & automate / Apps & transmissions

Apps & transmissions

Connect an agent to Telegram, Slack, a ticket system or any webhook. Inbound messages arrive as runs in a durable conversation per thread; replies go out through a schema-validated tool the agent calls. Credentials stay in your code; Foundry never stores or logs them.

What you get

  • An external event becomes a run of every agent instance whose playbook matches it. Events that match nothing start nothing, and predicates filter them before any model call.
  • Each external thread maps to one conversation, so a ticket or chat keeps its history across messages.
  • Replies are a generated tool (for example glove_app_helpdesk__tickets_send) whose input and output are checked against your schemas. It can require a human approval first.
  • Duplicate deliveries of the same event are ignored. Retries are safe.
  • OAuth, token refresh and secret storage stay in an adapter you write. Foundry stores only an opaque accessRef.

This page builds one complete integration: help-desk tickets flowing into the agents/support agent and replies flowing back out. The same shape works for chat apps, email or any provider with a webhook or an API you can poll.

PieceFile in agents/support/ → idRole
defineTransmissionEventevents/ticket-created.event.ts → ticket-createdA named kind of message, inbound or outbound.
defineTransmissionPredicatepredicates/priority-at-least.predicate.tsA parameterized yes/no check that playbooks reference.
defineTransmissiontransmissions/tickets.transmission.ts → ticketsThe transport contract: schemas, ingress and delivery adapters.
defineConnectionconnections/tickets-poller.connection.tsOptional supervised listener (socket, long poll) that emits inbound events.
defineAppapps/helpdesk.app.ts → helpdeskBundles transmissions, connections and extra tools. Instances install it.
definePlaybookAction, composePlaybookactions/triage.action.ts, agent.tsWhich events wake an instance and what it should do. See Playbooks.
defineAccount, defineInboundRoute, defineOutboundRoute, defineBindingtopology.ts (and the root foundry.application.ts); explicit idsRuntime data: which external account, which queue, who may send.

Events and predicates

agents/support/events/ticket-created.event.tstypescript
import { defineTransmissionEvent } from "glove-foundry";

export default defineTransmissionEvent({
  direction: "inbound",
  description: "A customer opened a help-desk ticket.",
});
agents/support/events/ticket-replied.event.tstypescript
import { defineTransmissionEvent } from "glove-foundry";

export default defineTransmissionEvent({
  direction: "outbound",
  description: "The agent posted a public reply on the ticket.",
});

A predicate receives the normalized event, the parameters a playbook supplies, and the ingress context ({ route, account? }), and returns Effect<boolean>. One predicate serves many playbooks with different thresholds.

agents/support/predicates/priority-at-least.predicate.tstypescript
import { Effect } from "effect";
import { defineTransmissionPredicate } from "glove-foundry";

/** Priority runs 1 (low) to 4 (urgent). Playbooks pass the threshold as a parameter. */
export default defineTransmissionPredicate({
  description: "The ticket's priority is at least parameters.minimum (default 3).",
  match: (event: { readonly priority: number }, parameters) =>
    Effect.succeed(event.priority >= Number(parameters.minimum ?? 3)),
});

The transmission

A transmission describes a transport, not a vendor SDK: what arrives, how to check and normalize it, what can be sent back, and how to deliver it. All schemas are Effect Schemas and all adapters return Effects.

agents/support/transmissions/tickets.transmission.tstypescript
import { Effect, Schema } from "effect";
import { defineTransmission } from "glove-foundry";
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";

/** Declared authority. A binding can only grant capabilities listed here. */
export const replyToTicket = {
  id: "tickets:reply",
  description: "Post a public reply on a ticket",
  account: "required",
  effect: "write",
} as const;

/** The provider's webhook body, exactly as the help desk sends it. */
export const HelpdeskWebhook = Schema.Struct({
  event_id: Schema.String,
  queue: Schema.String,
  ticket: Schema.Struct({
    id: Schema.String,
    subject: Schema.String,
    description: Schema.String,
    priority: Schema.Literal("low", "normal", "high", "urgent"),
    requester_email: Schema.String,
  }),
});

const PRIORITY = { low: 1, normal: 2, high: 3, urgent: 4 } as const;

// your code: the provider SDK. This fake keeps the sample runnable.
const helpdeskApi = {
  async reply(_token: string, ticketId: string, _body: string, _signal: AbortSignal) {
    return { commentId: `comment_${ticketId}_${Date.now()}` };
  },
};

export default defineTransmission({
  name: "Tickets",
  description: "Help-desk tickets in, public ticket replies out",
  account: {
    required: true,
    metadata: Schema.Struct({ subdomain: Schema.String }),
  },
  capabilities: [replyToTicket],
  events: [ticketCreated, ticketReplied],
  inbound: {
    config: Schema.Struct({ queue: Schema.String }),
    // The normalized event that predicates, playbooks and the agent see.
    event: Schema.Struct({
      ticketId: Schema.String,
      subject: Schema.String,
      body: Schema.String,
      priority: Schema.Number,
      requester: Schema.String,
    }),
    adapter: {
      // Refuse events for a queue this route does not serve.
      authenticate: (raw, { route }) =>
        Schema.decodeUnknown(HelpdeskWebhook)(raw).pipe(
          Effect.map((hook) => hook.queue === route.config.queue),
          Effect.orElseSucceed(() => false),
        ),
      // Provider shape in, your event shape out.
      normalize: (raw) =>
        Schema.decodeUnknown(HelpdeskWebhook)(raw).pipe(
          Effect.map(({ ticket }) => ({
            ticketId: ticket.id,
            subject: ticket.subject,
            body: ticket.description,
            priority: PRIORITY[ticket.priority],
            requester: ticket.requester_email,
          })),
          Effect.orDie,
        ),
    },
    classify: () => Effect.succeed(ticketCreated),
    predicates: [priorityAtLeast],
  },
  outbound: {
    config: Schema.Struct({ queue: Schema.String }),
    input: Schema.Struct({ ticketId: Schema.String, body: Schema.String }),
    output: Schema.Struct({ commentId: Schema.String }),
    // Long replies wait for a human approval before delivery.
    requiresPermission: (input: { readonly body: string }) => input.body.length > 1_500,
    // Secret-safe trace data. Without observe, the payload is not retained at all.
    observe: (input: { readonly ticketId: string; readonly body: string }) => ({
      ticketId: input.ticketId,
      characters: input.body.length,
    }),
    adapter: {
      deliver: (input: { readonly ticketId: string; readonly body: string }, context) =>
        context.withAccountSession
          ? context
              .withAccountSession("tickets:reply", (session) =>
                Effect.tryPromise(() =>
                  helpdeskApi.reply(
                    (session as { readonly token: string }).token,
                    input.ticketId,
                    input.body,
                    context.signal,
                  ),
                ),
              )
              .pipe(Effect.orDie)
          : Effect.die(new Error("Add accountSessions to the agent to reply on tickets.")),
    },
  },
});
FieldMeaning
name, descriptionRequired. Shown in the inspector and the outbound tool description.
account{ required, metadata }. metadata is the schema for safe, descriptive account fields.
capabilities{ id, description, account, effect } records. Ids look like tickets:reply. Bindings may only grant declared ones.
eventsEvery event this transmission can receive or send. Playbooks and classify may only use these.
inbound.config / outbound.configSchema for each route’s config.
inbound.eventSchema of the normalized event.
inbound.adapterOptional { authenticate(raw, ctx), normalize(raw, ctx) }. Without it Foundry decodes raw against inbound.event. With it, the value normalize returns is used as-is.
inbound.classify(event, ctx) => Effect<event definition>. The result must be in events. Without it, the event name is read from a type, kind or event string field, falling back to "event".
inbound.predicatesThe predicates playbooks may reference.
inbound.serializeOptional (event, ctx) => Effect<string> replacing the default XML turn (below). ctx carries eventId, eventName, threadKey and the matched playbooks.
outbound.input / outputThe send tool’s payload schema and the delivery result schema. Both are enforced.
outbound.adapter.deliver(input, ctx) => Effect<output>. ctx: route, account?, grant, signal, withAccountSession?.
outbound.requiresPermissionboolean or (input) => boolean. Gates the send behind an approval.
outbound.project / renderMap the output to what the model sees / to UI-only render data (for example, drop image bytes from the model result).
outbound.observeMap the input to secret-safe trace data. Omit it and traces record no payload.
!

defineTransmission throws if account.metadata, inbound.config or outbound.config declares a field named like a credential (token, accessToken, refreshToken, secret, clientSecret, password, apiKey, credential, credentials, any casing). Put an opaque accessRef on the account instead.

The app

An app bundles transmissions, optional connections and extra tools into one installable unit. List it in the agent’s components to make it available; it does nothing for an instance until that instance installs it (see Tools, MCP & memory).

agents/support/apps/helpdesk.app.tstypescript
import { Effect } from "effect";
import { defineApp } from "glove-foundry";
import { z } from "zod";
import ticketsPoller from "../connections/tickets-poller.connection.js";
import tickets from "../transmissions/tickets.transmission.js";

const helpdesk = defineApp({
  description: "Receive help-desk tickets and reply on them",
  config: z.object({ queue: z.string().default("support") }),
  inbound: [tickets], // tickets can start runs on instances that install this app
  outbound: [tickets], // mounted as the tool glove_app_helpdesk__tickets_send
  connections: [ticketsPoller], // optional; omit it when tickets arrive by webhook
  install: ({ config, accountId }) =>
    Effect.succeed({
      tools: [
        {
          name: "helpdesk_queue",
          description: "Report which help-desk queue and account this agent serves",
          inputSchema: z.object({}),
          async do() {
            return { status: "success" as const, data: { queue: config.queue, accountId } };
          },
        },
      ],
    }),
});

export default helpdesk;
FieldMeaning
descriptionRequired.
configZod schema. install(helpdesk, { queue: "support" }) is type-checked against it and decoded again at runtime.
inbound / outboundTransmissions this app receives from / sends through. A transmission listed as inbound must have an inbound contract, and likewise for outbound. One app can own several of each.
transmissionsMixed list, for bidirectional definitions. Prefer inbound/outbound.
connectionsLong-lived listeners. Each must only emit transmissions this app owns.
install(ctx)Returns Effect<{ tools? } | void>. Runs on every assembly of an installing instance. ctx has config, accountId?, withAccountSession?, the agent/conversation/run ids and the current message, but no Glove instance.

The runtime learns about a transmission through the apps listed in some agent’s components. Transmission ids are global to the runtime: if two agents each define their own transmissions/tickets.transmission.ts, startup fails. Share one definition (import it from both apps) instead.

Accounts, routes and bindings

Topology is runtime data, so it carries explicit ids that a UI or database can own. An account is an external identity, a route is one inbound or outbound lane through a transmission, and a binding authorizes an instance to send. Every config and metadata object is decoded against the transmission’s schema when you define it.

agents/support/topology.tstypescript
import { defineAccount, defineInboundRoute, defineOutboundRoute } from "glove-foundry";
import tickets from "./transmissions/tickets.transmission.js";

export const acmeHelpdesk = defineAccount({
  id: "acme-helpdesk",
  transmission: tickets,
  externalAccountId: "acme", // the provider's id for this account
  label: "Acme help desk",
  accessRef: "vault://helpdesk/acme", // opaque: only your accountSessions adapter reads it
  metadata: { subdomain: "acme" }, // checked against tickets.account.metadata
});

export const ticketsInbound = defineInboundRoute({
  id: "tickets-inbound",
  transmission: tickets,
  account: acmeHelpdesk,
  visibility: "workspace",
  enabled: true,
  config: { queue: "support" }, // checked against tickets.inbound.config
});

export const ticketsOutbound = defineOutboundRoute({
  id: "tickets-outbound",
  transmission: tickets,
  account: acmeHelpdesk,
  visibility: "workspace",
  enabled: true,
  config: { queue: "support" }, // checked against tickets.outbound.config
});
agents/support/instances.tstypescript
import { defineAgentInstance, install } from "glove-foundry";
import support from "./agent.js";
import helpdesk from "./apps/helpdesk.app.js";
import { acmeHelpdesk } from "./topology.js";

export const supportDesk = defineAgentInstance(support, {
  id: "support-desk",
  workspaceId: "acme",
  installations: [install(helpdesk, { queue: "support" }, { account: acmeHelpdesk })],
});
foundry.application.tstypescript
import { MemoryFoundryDataAdapter, defineApplication, defineBinding } from "glove-foundry";
import helpdesk from "./agents/support/apps/helpdesk.app.js";
import { supportDesk } from "./agents/support/instances.js";
import { acmeHelpdesk, ticketsInbound, ticketsOutbound } from "./agents/support/topology.js";
import tickets, { replyToTicket } from "./agents/support/transmissions/tickets.transmission.js";

// Outbound delivery is authorized per instance: this grants support-desk the reply route.
const supportDeskReplies = defineBinding({
  id: "support-desk-replies",
  agent: supportDesk,
  application: helpdesk,
  transmission: tickets,
  account: acmeHelpdesk,
  route: ticketsOutbound,
  capabilities: [replyToTicket],
  enabled: true,
});

export default defineApplication({
  name: "Acme Support",
  data: new MemoryFoundryDataAdapter({ agents: [supportDesk] }),
  accounts: [acmeHelpdesk],
  routes: [ticketsInbound, ticketsOutbound],
  bindings: [supportDeskReplies],
});
RecordFieldsChange at runtime
Accountid, transmission, externalAccountId, label?, accessRef, metadataNo. Read-only via GET /api/accounts, which omits accessRef.
Inbound / outbound routeid, transmission, account?, visibility ("private" | "workspace"), enabled, configclient.putRoute / removeRoute (PUT /api/routes, DELETE /api/routes/:id)
Bindingid, agent, application?, transmission, account?, route?, capabilities, reply?, enabledclient.putBinding / removeBinding (PUT /api/bindings, DELETE /api/bindings/:id)
  • Outbound needs a binding. Delivery resolves a per-run grant from the instance’s enabled bindings. An instance with none fails with agent has no enabled bindings; a route not named by a binding’s route (or reply: { mode: "route", route }) fails with not authorized for outbound route. Inbound does not need a binding.
  • A binding is validated against an existing instance. Seed the instance in the data adapter (as above), or create the binding after client.createAgent(...) with client.putBinding(defineBinding({ agent: created, … })).
  • By default routes and bindings live in an in-memory topology store, re-seeded from defineApplication on every start. Pass services (your own TopologyStore, AccountDirectory and EventStore layers) to persist runtime edits.

Wire it into the agent

agents/support/agent.tstypescript
import { Effect } from "effect";
import { createAdapter } from "glove-core/models/providers";
import { composeAgent, composePlaybook, defineAgent } 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";

// your code: resolve the credential behind an account. Foundry never sees it.
async function helpdeskToken(_accountId: string): Promise<string> {
  return process.env.HELPDESK_API_TOKEN ?? "dev-token";
}

const urgentTickets = composePlaybook({
  name: "urgent-tickets",
  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." }],
  applications: [helpdesk],
  outbound: [{
    route: ticketsOutbound,
    application: helpdesk,
    account: acmeHelpdesk,
    event: ticketReplied,
    instruction: "Reply on the same ticket.",
  }],
});

export default defineAgent({
  description: "Answers help-desk tickets",
  components: composeAgent(helpdesk), // available, not yet mounted
  model: () => createAdapter({ provider: "openrouter", model: "openai/gpt-4.1-mini", stream: true }),
  systemPrompt: "You are Acme's support agent. Be brief and specific.",

  // The credential boundary. Foundry passes an accountId and an operation name.
  accountSessions: {
    identifier: "helpdesk-tokens",
    withSession: (request, use) =>
      Effect.promise(() => helpdeskToken(request.accountId)).pipe(
        Effect.flatMap((token) => use({ token })),
      ),
  },

  // A playbook is only valid on an instance that installed helpdesk.
  playbooks: (_agent, { installations }) =>
    installations.some((item) => item.kind === "application" && item.id === helpdesk.id)
      ? [urgentTickets]
      : [],
});

actions/triage.action.ts is one line: export default definePlaybookAction({ description: "…" }). Every playbook field is covered in Playbooks, schedules & sleep.

!

Playbooks returned by defineAgent({ playbooks }) are written onto the instance while it is assembled for a run. A freshly created instance therefore ignores inbound events until it has run once (send it any message). To react from the first event, attach the playbook as instance data (client.setAgentPlaybooks) or use a playbook subscription.

What happens when an event arrives

  1. The inbound route must exist, be inbound and be enabled.
  2. Foundry claims the delivery <routeId>:<eventId>. A completed claim returns the original runs; a concurrent duplicate waits up to 30 s for them.
  3. authenticate runs; false rejects the event (HTTP 400) and releases the claim so the provider can retry.
  4. normalize runs (or raw is decoded against inbound.event), then classify.
  5. Candidates are instances that installed an app owning this transmission as inbound. Each candidate’s playbooks are matched on transmission, route, event and predicate. Enabled subscriptions are matched too and may provision new instances.
  6. For each matched instance Foundry reuses or creates the conversation transmission:<routeId>:<agentId>:<threadKey> and enqueues one run whose message is the serialized event plus every matched playbook’s directives. The run’s source is { kind: "transmission", id: <routeId>, provider: <transmissionId>, eventId, threadKey }, its payload is the normalized event, and its context carries the matched playbookIds and outbound entries.
  7. The call returns the started runs. No match returns [] and nothing runs.

Without inbound.serialize, the agent receives deterministic XML (JSON is XML-escaped):

agent turnxml
<transmission direction="inbound" definition="tickets" route="tickets-inbound" event="ticket-created" event-id="evt_1001" thread="T-1001">
  <payload format="json">{&quot;body&quot;:&quot;Where is order 4417?&quot;,&quot;priority&quot;:4,&quot;requester&quot;:&quot;dana@example.com&quot;,&quot;subject&quot;:&quot;Order never arrived&quot;,&quot;ticketId&quot;:&quot;T-1001&quot;}</payload>
  <playbook id="playbook-3f9c…">
      <directive action="triage">
        <instruction>Work out what the customer needs, then reply on the ticket.</instruction>
      </directive>
      <outbound route="tickets-outbound" application="helpdesk" event="ticket-replied" account="acme-helpdesk">Reply on the same ticket.</outbound>
  </playbook>
</transmission>

Fire an inbound event

From any process that can reach the runtime:

terminalbash
curl -X POST http://127.0.0.1:4141/api/transmissions/tickets-inbound/fire \
  -H 'content-type: application/json' \
  -d '{
    "eventId": "evt_1001",
    "threadKey": "T-1001",
    "raw": {
      "event_id": "evt_1001",
      "queue": "support",
      "ticket": { "id": "T-1001", "subject": "Order never arrived",
        "description": "Where is order 4417?", "priority": "urgent",
        "requester_email": "dana@example.com" }
    }
  }'
# 202 Accepted -> the started runs, [] when no playbook matched:
# [{ "id": "…", "agent": "support", "status": "pending", "agentId": "support-desk",
#    "conversationId": "transmission:tickets-inbound:support-desk:T-1001", … }]
Body fieldRequiredMeaning
eventIdyesThe provider’s delivery id. Deduplication key together with the route id.
threadKeyyesThe external thread (ticket, chat, email thread). One conversation per key per instance.
rawnoWhatever your inbound.adapter expects, or the event itself when there is no adapter.
conversationKeynoA durable key that replaces threadKey for conversation lookup.
conversationScopeno"route" (default) isolates per route. "agent" joins every route that sends the same conversationKey; only use it after you have authenticated and linked the identities.

The body limit is 1 MiB. The endpoint sits behind your requestAuthorization adapter like every /api route (see Deploy & security). The typed client method is dispatchInbound, which takes the same fields plus routeId and resolves to ReadonlyArray<FoundryRun>.

Webhook recipe

Verify the provider’s signature where you have the raw bytes, then forward. This is a Next.js route handler; any HTTP framework works the same way.

app/api/helpdesk-webhook/route.tstypescript
import { createHmac, timingSafeEqual } from "node:crypto";
import { createFoundryClient } from "glove-foundry/client";

const foundry = createFoundryClient({
  baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141",
  // Only needed when foundry.application.ts sets requestAuthorization.
  authorization: {
    identifier: "helpdesk-webhook",
    headers: () => ({ authorization: `Bearer ${process.env.FOUNDRY_API_TOKEN ?? ""}` }),
  },
});

export async function POST(request: Request): Promise<Response> {
  const body = await request.text();
  const expected = createHmac("sha256", process.env.HELPDESK_WEBHOOK_SECRET ?? "")
    .update(body)
    .digest("hex");
  const signature = request.headers.get("x-helpdesk-signature") ?? "";
  if (
    signature.length !== expected.length ||
    !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    return new Response("invalid signature", { status: 401 });
  }

  const hook = JSON.parse(body) as { event_id: string; ticket: { id: string } };
  const runs = await foundry.dispatchInbound({
    routeId: "tickets-inbound",
    eventId: hook.event_id, // a provider retry reuses it, so nothing runs twice
    threadKey: hook.ticket.id, // one conversation per ticket
    raw: hook, // goes through tickets.inbound.adapter
  });
  return Response.json({ runIds: runs.map((run) => run.id) }, { status: 202 });
}

Connections: listeners Foundry supervises

When a provider pushes over a socket or must be polled, define a connection. Foundry starts it only while some instance (or subscription) has an enabled playbook for one of its transmissions on an enabled inbound route, runs one per agent definition, workspace, app and account, and restarts it when it fails.

agents/support/connections/tickets-poller.connection.tstypescript
import { Effect } from "effect";
import { defineConnection } from "glove-foundry";
import tickets, { HelpdeskWebhook } from "../transmissions/tickets.transmission.js";

// your code: ask the help desk for tickets created since the last poll.
async function fetchNewTickets(
  _queue: string,
  _signal: AbortSignal,
): Promise<ReadonlyArray<typeof HelpdeskWebhook.Type>> {
  return [];
}

export default defineConnection({
  description: "Polls the help desk for new tickets every 30 seconds",
  transmissions: [tickets],
  connect: (ctx) =>
    Effect.gen(function* () {
      yield* ctx.ready(); // the inspector now shows this connection as "connected"
      while (!ctx.signal.aborted) {
        for (const route of ctx.routes) {
          const batch = yield* Effect.tryPromise(() =>
            fetchNewTickets(String(route.config.queue), ctx.signal),
          );
          for (const hook of batch) {
            yield* ctx.receive({
              route,
              eventId: hook.event_id, // dedupe key: "<routeId>:<eventId>"
              threadKey: hook.ticket.id, // one conversation per ticket
              raw: hook,
            });
          }
        }
        yield* Effect.sleep("30 seconds");
      }
    }),
});
ctx memberMeaning
ready()Mark the connection connected.
receive(input)Dispatch one event. input: route, eventId, threadKey, raw, optional conversationKey, conversationScope, and awaitCompletion (wait until every started run finishes, to keep a chat transcript ordered).
routesThe enabled inbound routes this connection serves. Emitting through any other route fails.
account?, withAccountSession?The routes’ account and its session boundary (present when the agent defines accountSessions).
signalAborted when Foundry stops the connection.
applicationId, connectionId, definitionId, workspaceIdWhere this connection belongs.

If connect fails or simply returns, Foundry reconnects with exponential backoff starting at 250 ms and capped at 30 s. Status (connecting, connected, reconnecting, disconnected, failed), attempts and the last error are shown on the inspector’s Automations page, returned by GET /api/application-connections, and a connection can be restarted with POST /api/application-connections/:id/reconnect.

Outbound: the generated send tool

Every outbound transmission of an installed app becomes one tool on that instance, named by installedApplicationTransmissionToolName(app, transmission):

namingtext
glove_app_<app id>__<transmission id>_send      ("/" becomes "__", "-" becomes "_")

helpdesk + tickets              -> glove_app_helpdesk__tickets_send
support-desk + email/replies    -> glove_app_support_desk__email__replies_send
  • Input is { routeId, payload }. payload is outbound.input; routeId is limited to the outbound routes the instance’s enabled playbooks list for this app, or any route the run is authorized for when none do.
  • The payload is decoded in the agent process, then delivered by the parent runtime, which checks the route, the app installation and the run’s grant, calls deliver, and decodes the result with outbound.output. Credentials and account sessions never enter the agent process.
  • The tool result is project(output) (or the output); render(output) becomes UI-only render data.
  • The payload travels to the parent through a private file (mode 0600) that is deleted once settled; the event stream only carries observe(input).
  • Stopping the run aborts context.signal inside deliver.

Every agent also has the generic glove_foundry_transmit tool ({ routeId, payload }) for routes not tied to an app; it is listed with the other built-in tools. Operators can deliver directly with POST /api/transmissions/:routeId/deliver ({ agentId, runId, payload }), which applies the same grant check.

Delivery idempotency

Every inbound event is claimed under <routeId>:<eventId> through FoundryDataAdapter.claimInboundDelivery before anything runs. A repeat returns the runs of the first delivery; a failure releases the claim. With FileFoundryDataAdapter or your own durable adapter, claims survive restarts, so use the provider’s own delivery id as eventId. A database-backed adapter must implement the claim atomically. See Persistence.

The credential boundary

Foundry never runs OAuth, never refreshes a token and never chooses where secrets live. You give it one adapter, accountSessions on defineAgent (shown above): { identifier, withSession(request, use) }, where request is { accountId, operation, agentId, conversationId, workspaceId } and you call use(session) with whatever your provider code needs. Foundry exposes it as withAccountSession(operation, use):

WhereWhat you get
outbound.adapter.delivercontext.withAccountSession(operation, use) when the route has an account and the agent defines accountSessions. Also context.account (including accessRef).
defineConnection connectctx.withAccountSession and ctx.account, same conditions.
defineApp installctx.withAccountSession when the installation selected an account (install(app, config, { account })) and the agent defines accountSessions.
  • accessRef is an opaque pointer for your adapter. Foundry does not resolve it, does not put it in manifests, and omits it from GET /api/accounts.
  • The session value you pass to use is never written to Foundry data or traces.
  • Installation config may not contain accountId; select the account with install’s third argument.

Triage with a classifier

To decide which event a message is, or whether a playbook should wake, with a small classifier model instead of string matching, use glove-classifier/foundry. classifyInbound(...) returns a function for inbound.classify that maps a choice question’s answer to your event definitions (with a fallback below minConfidence). classifierPredicate(...) returns options for defineTransmissionPredicate that match when the answers satisfy where; playbooks can override min, max, choice or where through their predicate parameters. See Classifier models → Foundry.