@foundry-ai-dev/agent-sdk
v0.3.0
Published
Builder SDK for Foundry agents — mount a handler, receive dispatched events, call back into Foundry with high-level helpers.
Downloads
1,595
Readme
@foundry-ai-dev/agent-sdk
Builder SDK for Foundry agents. Mount a handler in any Express app, receive dispatched events from Foundry, and call back into Foundry through typed resource clients.
Slack is the first resource client shipped in v1. GitHub, Gmail, Calendar, Drive, and Meet are on the roadmap and will follow the same shape — same constructor, same auth, same env vars, same 401 retry behavior. The Slack examples below are the reference; every future client mirrors them one-for-one.
Install
npm install @foundry-ai-dev/agent-sdk expressQuick start
Here's the smallest agent that does something — a Slack echo handler. Slack is the only resource client wired up today, but the shape (task.eventType switch + ctx.<integration>.<method> call) is what every future integration will look like.
import express from 'express'
import { mountAgent } from '@foundry-ai-dev/agent-sdk'
const app = express()
mountAgent(app, async (task, ctx) => {
// Slack is the first resource client. Future integrations land under the
// same `ctx.<integration>` namespace — ctx.github, ctx.gmail, ctx.calendar,
// ctx.drive, ctx.meet — with their own event types and typed methods.
if (task.eventType === 'slack.message') {
const text = (task.event as { text?: string }).text ?? ''
await ctx.slack.postMessage({
channel: (task.event as { channel: string }).channel,
text: `echo: ${text}`,
})
}
})
// Foundry forwards traffic to port 8080 inside the machine.
// Bind to 0.0.0.0 so Foundry's proxy can reach you (not 127.0.0.1).
app.listen(8080, '0.0.0.0', () => {
console.log('agent listening on 0.0.0.0:8080')
})You must listen on port 8080. Foundry machines forward traffic from 80/443 to internal port 8080. If your server binds to a different port, the agent runs but nothing reaches it. (V2 will let you configure this.)
Foundry resource clients (the pattern)
A Foundry resource client is a thin, typed POST-with-Bearer-JWT wrapper around ${apiUrl}/api/agents/<tool>/<action>. Every client — Slack today, GitHub/Gmail/Calendar/Drive/Meet tomorrow — has the same shape:
- One uniform deps bag:
{ apiUrl, ctxToken?, auth?, fetch? }. - One token rule: use the cached
ctxToken(passed in from a/rundispatch) first; if absent, mint a fresh JWT viaauth.getToken(). - One retry rule: on a 401, if
authis present, callauth.invalidate()+getToken()and retry the call exactly once. A second 401 (or any other non-ok status) throws a structured error likectx.<tool>.<method> failed (401): <detail>. - One transport:
POST ${apiUrl}/api/agents/<tool>/<action>withAuthorization: Bearer <jwt>and a JSON body.
The Slack client at packages/agent-sdk/src/clients/slack.ts is the reference implementation. GitHub, Gmail, Calendar, Drive, and Meet clients will copy its file structure exactly — only the input types, method names, and the <tool>/<action> path segment change.
Provisioned env vars
When Foundry provisions a machine for your agent, it bakes the following env vars in. You don't set these by hand in production — they're guaranteed to be present.
| Var | What it is | Used by |
|---|---|---|
| FOUNDRY_API_URL | Where the SDK calls back (e.g. https://api.foundry.dev) | mountAgent, createAuthClient, all resource clients |
| FOUNDRY_AGENT_SIGNING_KEY | Shared HMAC secret used to verify inbound /run requests | mountAgent |
| FOUNDRY_MACHINE_RUNTIME_ID | This machine's public credential id (mch_<hex>) | createAuthClient |
| FOUNDRY_MACHINE_RUNTIME_SECRET | This machine's plaintext secret — never stored on Foundry's side, only hashed | createAuthClient |
mountAgent reads all four automatically. Your code just calls mountAgent(app, handler) and binds your server to port 8080.
Local development
For tests or running outside a provisioned machine, set the vars by hand:
export FOUNDRY_API_URL=http://localhost:4000
export FOUNDRY_AGENT_SIGNING_KEY=<grab from your dev agent's row in the `agents` table>
export FOUNDRY_MACHINE_RUNTIME_ID=mch_localdev
export FOUNDRY_MACHINE_RUNTIME_SECRET=secret_localdevDeployment topology
Foundry dispatcher
│
▼ POST https://<your-agent>.foundry.dev/run
│ (HMAC-signed, fire-and-forget)
│
Foundry proxy (80/443)
│
▼ forwards to internal_port 8080
│
Your agent (app.listen(8080, '0.0.0.0'))
│
│ resource-client callbacks (e.g. Slack), token mints, etc.
▼
POST https://<foundry-api>/api/agents/slack/post-message ← Slack today
/api/agents/<tool>/<action> ← every future client, same shape
/api/runtime/auth/machine-access- Foundry POSTs
/runfor every dispatch. Your handler returns 200; Foundry won't retry. - No
/healthprobe — don't bother adding one. - Callbacks the SDK makes for you (resource clients + auth) target the URL from
FOUNDRY_API_URL.
What mountAgent does
- Registers
POST /runon the Express app (override withoptions.path). - Verifies the inbound HMAC signature against
FOUNDRY_AGENT_SIGNING_KEY. - Builds a
ctxwith one typed helper per installed integration plus a logger. Today that'sctx.slack.postMessageandctx.logger; as more integrations ship, they land under the same namespace (ctx.github,ctx.gmail, …) with identical construction and retry behavior. Every helper closes over the per-dispatch JWT. - Calls your handler with
(task, ctx). - Returns
200 { ok: true }even on handler errors so Foundry's dispatcher doesn't retry blindly. Errors are logged viactx.logger.
mountAgent is intentionally narrow — it only handles the webhook. Auth and resource clients are constructed by your code, not by mountAgent.
Calling Foundry from anywhere in your code
The webhook handler is one way to use the SDK, not the only way. A cron job, queue consumer, or any other route can call Foundry resource clients directly. The pattern is the same for every client — Slack today, every future integration the same way:
import { createAuthClient, createSlackClient } from '@foundry-ai-dev/agent-sdk'
const auth = createAuthClient() // reads FOUNDRY_* env vars
const slack = createSlackClient({
apiUrl: process.env.FOUNDRY_API_URL!,
auth,
})
await slack.postMessage({ channel, text: 'hi' })Once a future integration ships, the only thing that changes is the factory name and the method:
// Illustrative — not yet shipped. Same deps bag, same auth, same 401 retry.
// const github = createGithubClient({ apiUrl: process.env.FOUNDRY_API_URL!, auth })
// await github.commentOnIssue({ repo, issueNumber, body: 'hi' })createAuthClient() is a singleton when called with no args — every file that calls it gets the same instance and shares its cache. Call it once at module top in any file; the SDK handles the rest.
Long-running webhook handlers: opt in to 401 retries
The default ctx.<integration> helpers use the per-dispatch 5-minute ctxToken. If a handler runs longer than 5 minutes, the token expires. Pass an auth client to mountAgent so every ctx client (ctx.slack today, ctx.<integration> for any future client) can re-mint on 401:
import { mountAgent, createAuthClient } from '@foundry-ai-dev/agent-sdk'
mountAgent(app, handler, { auth: createAuthClient() })Listening on the right port
Foundry forwards traffic to internal port 8080 on every machine. Your server must:
- Listen on port 8080
- Bind to
0.0.0.0(not127.0.0.1— Foundry's proxy lives in another network namespace)
app.listen(8080, '0.0.0.0')If you bind to a different port, the agent runs but nothing reaches it. (V2 will let you configure the port — Foundry will read it from your manifest and route accordingly.)
Trust model
Every dispatch is HMAC-signed with FOUNDRY_AGENT_SIGNING_KEY. Every callback uses a short-lived (5-minute) JWT issued by Foundry — the trust layer re-checks permission_grants on every call, so a revoked permission takes effect on the next attempt. If the JWT expires mid-handler, the SDK transparently re-mints one via the auth client.
You never touch raw OAuth tokens. Foundry holds them — Slack today, every future integration the same way — and the SDK lets you act through Foundry.
What's shipped today
| Integration | Resource client | Status |
|---|---|---|
| Slack | createSlackClient, ctx.slack.postMessage | Shipped in v1 |
| GitHub | createGithubClient | Planned — same shape |
| Gmail | createGmailClient | Planned — same shape |
| Google Calendar | createCalendarClient | Planned — same shape |
| Google Drive | createDriveClient | Planned — same shape |
| Google Meet | createMeetClient | Planned — same shape |
No install or import for the planned clients yet — they will appear in this README as they ship. The pattern (deps bag, token rule, 401 retry, transport) is fixed and won't change between integrations.
License
MIT
