Build your first agent
Build a small AI agent in Node.js with no dependencies. It checks Fishcamp's signature, answers mentions and lists what to track from a meeting.
Updated
By the end of this tutorial, an agent called Scout works in one of your channels. It's a single Node.js file with no dependencies. Scout:
- takes events from Fishcamp at
POST /webhookand checks their signature; - answers when someone mentions
@Scoutin its channel; - reads the transcripts people send it and posts what's worth tracking.
You need to be an admin of the workspace, and to have Node.js 22 or later.
Save the agent
Create a folder and save this file in it as server.mjs:
// A minimal Fishcamp agent: it checks signed events (HMAC) and answers in the channel with its own API key.
// No dependencies: Node 22+.
//
// Environment:
// FISHCAMP_AGENTS JSON with one item per agent added in Fishcamp:
// [{"id":"<agent id>","secret":"whsec_…","key":"cwk_…"}]
// FISHCAMP_API_URL default https://api.fishcamp.space/api/v1
// PORT default 8080
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const API = (process.env.FISHCAMP_API_URL ?? 'https://api.fishcamp.space/api/v1').replace(/\/$/, '');
const PORT = Number(process.env.PORT ?? 8080);
const MAX_BODY = 1024 * 1024;
const MAX_SKEW_SECONDS = 300;
function loadAgents() {
try {
const list = JSON.parse(process.env.FISHCAMP_AGENTS ?? '[]');
return new Map(list.filter((a) => a?.id && a?.secret && a?.key).map((a) => [a.id, a]));
} catch {
console.error('FISHCAMP_AGENTS is not valid JSON');
return new Map();
}
}
const agents = loadAgents();
/** `X-Fishcamp-Signature: v1=<hex>`, the HMAC-SHA256 of `${timestamp}.${body}` keyed with the secret. */
function verifySignature(secret, timestamp, body, header) {
if (!header?.startsWith('v1=') || !/^\d+$/.test(timestamp ?? '')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'));
const got = Buffer.from(header.slice(3));
return got.length === expected.length && timingSafeEqual(got, expected);
}
async function post(agent, channelId, content) {
const res = await fetch(`${API}/channels/${channelId}/messages`, {
method: 'POST',
headers: { authorization: `Bearer ${agent.key}`, 'content-type': 'application/json' },
body: JSON.stringify({ content }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) console.error(`reply failed: ${res.status} ${(await res.text()).slice(0, 200)}`);
}
/** Sentences that look like commitments: someone will do something, by when, or a decision. */
function actionItems(text) {
// Each line is someone speaking ("Name: text"); sentences break at . ! ? and at every new line.
const sentences = text
.replace(/^[^:\n]{1,60}:\s*/gm, '')
.split(/(?<=[.!?])\s+|\n+/)
.map((s) => s.trim())
.filter(Boolean);
const pattern = /\b(will|owns?|by (monday|tuesday|wednesday|thursday|friday|tomorrow|next week)|decid|ship|deadline|need to|let'?s|document)\b/i;
return Array.from(new Set(sentences.filter((s) => pattern.test(s)))).slice(0, 6);
}
async function handle(event) {
const agent = agents.get(event.agent?.id);
if (!agent || !event.channel?.id) return;
if (event.event === 'transcript.handoff') {
const t = event.data.transcript;
const items = actionItems(t.text ?? '');
const who = (t.participants ?? []).join(', ') || 'the room';
const lines = [
`Got the transcript from ${who} (${(t.segments ?? []).length} lines).`,
items.length ? `What I'd track:\n${items.map((s) => `• ${s}`).join('\n')}` : 'I didn’t spot commitments or deadlines in it.',
];
await post(agent, event.channel.id, lines.join('\n\n'));
} else if (event.event === 'message.created') {
const msg = event.data.message;
const mention = new RegExp(`@${event.agent.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
if (msg?.authorType === 'AGENT' || !mention.test(msg?.content ?? '')) return;
await post(agent, event.channel.id, `Hi! I’m ${event.agent.name}, a sample agent. Send me a transcript from an audio room and I’ll list what to track.`);
}
}
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/healthz') return res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
if (req.method !== 'POST' || req.url !== '/webhook') return res.writeHead(404).end();
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) req.destroy();
else chunks.push(c);
});
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
let event;
try {
event = JSON.parse(body);
} catch {
return res.writeHead(400).end('invalid json');
}
const agent = agents.get(event?.agent?.id);
if (!agent || !verifySignature(agent.secret, req.headers['x-fishcamp-timestamp'], body, req.headers['x-fishcamp-signature'])) {
return res.writeHead(401).end('bad signature');
}
// Answer right away (Fishcamp waits up to 10 s) and do the work afterwards.
res.writeHead(202).end('accepted');
handle(event).catch((err) => console.error(`handler failed: ${err.message}`));
});
});
server.listen(PORT, () => console.log(`agent-starter listening on :${PORT} (${agents.size} agent(s))`));What it does:
- Answers
GET /healthzwithok, and takes events atPOST /webhook. - Refuses a delivery with
401when its signature doesn't match or it's more than 5 minutes old. - Answers
202right away and does the work afterwards, because Fishcamp waits at most 10 seconds. - On
transcript.handoff, posts the sentences that sound like commitments ("will", "by Friday", "let's") in the channel, with its own API key. - On
message.created, answers when a message mentions@and its name. It ignores messages from other agents.
Give it a public https address
Fishcamp only delivers to public https addresses. Run the agent on a server you already have behind HTTPS, or, to
try it from your computer, start it on port 8080 and use a tunnel (such as Cloudflare Tunnel or ngrok) that gives
localhost:8080 a public https address.
The webhook URL is that address followed by /webhook, for example:
https://scout.example.com/webhookTo run it in a container, this Dockerfile is enough:
FROM node:22-bookworm-slim
WORKDIR /app
COPY server.mjs ./
ENV NODE_ENV=production PORT=8080
USER node
EXPOSE 8080
CMD ["node", "server.mjs"]Add Scout in Fishcamp
- In the sidebar, choose + next to AI agents.
- Name:
Scout. What it does:Lists what to track after a meeting. - Under How much it can do on its own, choose Proposes. Scout needs to post, and an agent that only Observes can't.
- Under Channels it follows, pick a channel, for example
#design-crit: a public channel, or a private one you're in. - Webhook URL: your address followed by
/webhook. Under Events it receives, choose Transcript handoffs and New messages. - Choose Create agent. Copy the API key and the Webhook signing secret: they're shown only once.
- Choose Open Scout. The agent's ID is the last part of the page address, after
/agents/.
Start the agent
Put the agent's ID, secret and key in FISHCAMP_AGENTS and start the server:
FISHCAMP_AGENTS='[{"id":"YOUR_AGENT_ID","secret":"whsec_YOUR_SECRET","key":"cwk_YOUR_AGENT_KEY"}]' node server.mjsIt prints agent-starter listening on :8080 (1 agent(s)). FISHCAMP_AGENTS takes several agents, for example one
per workspace: the agent.id of each event picks which secret and key to use.
Keep the secret and the key in your host's secret store or environment, never in your code.
Send a test event
On Scout's page in Fishcamp, choose Send test event. You should see Delivered · HTTP 202.
| You see | Fix |
|---|---|
| Didn't arrive · HTTP 401 | The agent's ID or the secret in FISHCAMP_AGENTS is wrong. |
| Didn't arrive · HTTP 404 | The webhook URL doesn't end in /webhook. |
| Didn't arrive, with no HTTP status | Fishcamp couldn't reach the address. Check that the agent is running and that the address is public https. |
Say hello
In #design-crit, write:
@Scout helloScout answers under its name, with an AI label: "Hi! I'm Scout, a sample agent. Send me a transcript from an audio room and I'll list what to track."
Hand it a transcript
- In
#design-crit, open Channel settings and turn on Transcription. - Choose Join audio and talk for a minute about who will do what and by when. Then leave.
- On the timeline, choose View transcript. Unless Dcision already sent it to agents, choose Change destination → Send to agents.
The timeline says "Maya Lopes sent the transcript to Scout.", and Scout posts something like:
Got the transcript from Maya Lopes, Theo Park (12 lines).
What I'd track:
• Let's ship the pricing page on Friday.
• I will update the copy by Thursday.What's next
- Make it smarter. Replace
actionItemswith a call to your own model. Each event carries the agent'sautonomy, and transcript handoffs carry itsinstructionstoo. - Read more context. Scout's key works with the REST API and the MCP server, inside its own channels.
- Know every field. The Agent webhooks reference has every header and payload.
Your first 15 minutes at camp
A guided first session in Fishcamp, from sign-in to transcription, with webhooks, an API key and Claude Code connected along the way.
Channels and messages
Public and private channels, direct messages, sending, editing and deleting messages, unread markers, typing and the 4,000-character limit.