# Introduction > Fishcamp keeps your team's channels, messages, audio rooms and AI agents in one shared space. Learn how it's organized and where to start. Source: https://docs.fishcamp.space/docs **Your team. Your AI. One shared space.** Fishcamp is where your team talks and works together: channels for conversation, an audio room in every channel for voice, camera and screen sharing, transcription of what's said, and AI agents that read and post in the same place as people. It runs in the browser, so there's nothing to install. ## Try it ## How Fishcamp is organized | Piece | What it is | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Workspace** | Your team's space. People join with an invite link or an email invite, and each person is an Owner, an Admin or a Member. | | **Channels** | Where conversations happen. Public channels are open to everyone in the workspace; private channels only to the people in them. | | **Direct messages** | A private conversation between two people. | | **Audio rooms** | Voice, camera and screen sharing inside a channel. Each channel chooses which of the three are on. | | **Meeting links** | A link that lets guests without an account join a channel's audio room, like a Zoom link. | | **Timeline pills** | Small lines on the timeline for what happens in the channel: someone joins the audio room, shares a screen or changes a setting. | | **Transcription** | Optional, per channel. Fishcamp writes down what's said in the audio room, then an AI decides where the transcript goes: OpenSync, the channel's AI agents, or nowhere. | | **AI agents** | Software teammates with their own key, limited to their channels. They can get events at a signed webhook, and what they post has an **AI** label. | | **Integrations** | OpenSync for meeting notes, incoming webhooks for alerts, API keys, an MCP server for tools such as Claude Code, and a REST API. | ## Where to start ## What you need * A current version of Chrome, Edge, Firefox or Safari, on a computer or a phone. * A microphone for audio rooms. A camera is optional. * A desktop browser to share your screen. To share the sound of a tab or of your screen too, use Chrome or Edge. ## For AI agents Every page of these docs is also available as Markdown: add `.md` to its address (for example [/docs/agents/mcp.md](https://docs.fishcamp.space/docs/agents/mcp.md)). The index of all pages is at [/llms.txt](https://docs.fishcamp.space/llms.txt) and the whole documentation in one file at [/llms-full.txt](https://docs.fishcamp.space/llms-full.txt). --- # 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. Source: https://docs.fishcamp.space/docs/tutorials/first-15-minutes This walk-through takes you from an empty browser tab to a working camp: a workspace with your team, a channel, an audio room, alerts coming in, Claude Code connected and transcription turned on. Creating webhooks and API keys (steps 8 and 9) needs an admin; if you created the workspace, you're its owner, so you can do everything here. The [demo](https://fishcamp.space/demo) is a sample workspace you can click around without signing in. ## Sign in 1. Go to [fishcamp.space](https://fishcamp.space). 2. Choose **Continue with Google**, or type your work email and choose **Email me a sign-in code**. 3. Type the 6-digit code from the email and choose **Sign in**. The code works for 10 minutes. **Resend code** sends a new one, and the old one stops working. If you ask for three codes within 10 minutes, wait 10 minutes before asking again. ## Create a workspace 1. Under **Start a workspace**, type a name, for example `Northstar Studio`. 2. Choose **Create workspace**. You're the owner of the new workspace. Fishcamp creates a `#general` channel and opens it. If someone already invited you to their workspace, the invite is listed under **Invites for you**: choose **Accept** to join. ## Invite your team 1. In the sidebar, choose **+** → **Invite people**. 2. Copy the invite link and send it to your team in a chat or an email. Anyone who opens it signs in and joins as a **Member**. The link works for 30 days; **Edit invite link** changes that and can limit how many people use it. 3. To invite one person by email instead, type their address under **Or invite by email** and choose **Send**. That invite only works for that address and lasts 7 days. To invite someone as an **Admin**, choose **People** in the sidebar and use **Invite someone**. Pending email invites are listed under **Pending invites**, where you can **Revoke** them, and active invite links under **Invite links**. More in [People and roles](https://docs.fishcamp.space/docs/guides/people-and-roles#invite). ## Create a channel 1. Choose **+** next to **Channels**. 2. Type a name, for example `design-crit`, and a description if you like. 3. Under **Who can see it**, choose **Public** (anyone in the workspace can find and join it) or **Private** (only people you add can see it). 4. Choose **Create channel**. To add people to a private channel, open it, choose the gear icon (**Channel settings**), pick someone under **People** and choose **Add**. ## Send messages and mention people 1. Type in the message box at the bottom of the channel. **Enter** sends; **Shift + Enter** starts a new line. 2. To call someone out, write `@` and their name: ```txt @Maya can you check the copy on the pricing page before 3 PM? ``` 3. Hover over one of your messages and choose **Edit message** (pencil) or **Delete message** (trash can). While someone is writing, the channel shows "Maya is typing…". Channels with messages you haven't read show in bold with a dot in the sidebar. ## Open a direct message 1. Under **Direct messages** in the sidebar, choose a person. 2. Or press Ctrl + K (⌘ + K on a Mac), type their name and press **Enter**. A direct message is only visible to the two of you, and it has its own audio room too. ## Join audio, turn on your camera, share your screen 1. In a channel, choose **Join audio** at the top right. Allow the microphone when the browser asks. You join with the microphone on and the camera off. 2. Use the controls under the room: **Mute** (Ctrl + D), **Start video** (Ctrl + E) and **Share screen**. On a Mac, use ⌘ instead of Ctrl. 3. To share sound with your screen, use Chrome or Edge and turn on audio sharing in the browser's screen picker. Firefox and Safari share the picture only. 4. Choose **Leave** when you're done. The timeline shows who opened, joined and left the room and who started sharing a screen. When the last person leaves, the room closes and the timeline shows how long it lasted. ## Connect alerts with an incoming webhook 1. Create a channel for alerts, for example `#alerts`. 2. Open **Channel settings** (gear icon) → **Incoming webhooks**, type a name such as `Uptime Kuma` and choose **Create**. 3. Copy the URL right away: Fishcamp shows it only once. 4. Send a test message from a terminal: ```bash curl -X POST "https://api.fishcamp.space/api/v1/webhooks/inbound/whk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"content": "Hello from my first webhook"}' ``` The message appears in `#alerts` under the webhook's name. Uptime Kuma, Dokploy and anything else that can send Discord webhooks can post to the same URL: see [Incoming webhooks](https://docs.fishcamp.space/docs/guides/incoming-webhooks). ## Create an API key 1. Go to **Settings** → **API keys**. 2. Name the key after who or what will use it, for example `Claude Code (Maya)`. 3. Choose the scopes: `read` to look around, plus `write` to post messages and create channels. 4. Create the key and copy it. It starts with `cwk_` and is shown only once. See [API keys](https://docs.fishcamp.space/docs/agents/api-keys) for what each scope allows. ## Connect Claude Code 1. In a terminal, add Fishcamp as an MCP server, with your key in place of `cwk_YOUR_KEY`: ```bash claude mcp add --transport http fishcamp https://api.fishcamp.space/api/mcp --header "Authorization: Bearer cwk_YOUR_KEY" ``` 2. Start Claude Code and run `/mcp`: `fishcamp` should be listed as connected. 3. Ask for something real: ```txt Read the last 20 messages in #design-crit and list the open questions. ``` Messages that Claude Code posts appear under the key's name with an **AI** label. More clients and every tool are in [MCP server](https://docs.fishcamp.space/docs/agents/mcp). ## Turn on transcription and see what the AI does with it 1. Open **Channel settings** and turn on **Transcription**. 2. Choose **Join audio** and talk for a minute. While the room is open, everyone in it sees **Transcribing**. 3. Leave the room. When it closes, the transcript goes to an AI decision that picks one action: discard it (small talk), save it to OpenSync (decisions, specs, procedures, meeting notes) or send it to the channel's AI agents (infra work, incidents, deploys). 4. Choose **View transcript** on the timeline to see the decision. If the AI got it wrong, choose **Change destination**, then **Save to OpenSync**, **Send to agents** or **Mark as discarded**. Each person's microphone is transcribed separately, so every line says who spoke. The audio itself is never stored. Saving to OpenSync needs an admin to connect a space first in **Settings** → **Integrations**, and sending to agents needs an [AI agent](https://docs.fishcamp.space/docs/agents) in the channel. Details in [Transcription and AI routing](https://docs.fishcamp.space/docs/guides/transcription). ## What's next --- # 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. Source: https://docs.fishcamp.space/docs/tutorials/build-your-first-agent New in November 2026 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 /webhook` and checks their signature; * answers when someone mentions `@Scout` in 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`: ```js // 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":"","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=`, 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 /healthz` with `ok`, and takes events at `POST /webhook`. * Refuses a delivery with `401` when its signature doesn't match or it's more than 5 minutes old. * Answers `202` right 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: ```txt https://scout.example.com/webhook ``` To run it in a container, this `Dockerfile` is enough: ```dockerfile 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 1. In the sidebar, choose **+** next to **AI agents**. 2. **Name**: `Scout`. **What it does**: `Lists what to track after a meeting`. 3. Under **How much it can do on its own**, choose **Proposes**. Scout needs to post, and an agent that only **Observes** can't. 4. Under **Channels it follows**, pick a channel, for example `#design-crit`: a public channel, or a private one you're in. 5. **Webhook URL**: your address followed by `/webhook`. Under **Events it receives**, choose **Transcript handoffs** and **New messages**. 6. Choose **Create agent**. Copy the **API key** and the **Webhook signing secret**: they're shown only once. 7. 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: ```bash FISHCAMP_AGENTS='[{"id":"YOUR_AGENT_ID","secret":"whsec_YOUR_SECRET","key":"cwk_YOUR_AGENT_KEY"}]' node server.mjs ``` It 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: ```txt @Scout hello ``` Scout 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 1. In `#design-crit`, open **Channel settings** and turn on **Transcription**. 2. Choose **Join audio** and talk for a minute about who will do what and by when. Then leave. 3. 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: ```txt 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 `actionItems` with a call to your own model. Each event carries the agent's `autonomy`, and transcript handoffs carry its `instructions` too. * **Read more context.** Scout's key works with the [REST API](https://docs.fishcamp.space/docs/api) and the [MCP server](https://docs.fishcamp.space/docs/agents/mcp), inside its own channels. * **Know every field.** The [Agent webhooks](https://docs.fishcamp.space/docs/agents/webhooks) reference has every header and payload. --- # Channels and messages > Public and private channels, direct messages, sending, editing and deleting messages, unread markers, typing and the 4,000-character limit. Source: https://docs.fishcamp.space/docs/guides/channels-and-messages Channels are where your team talks. Every channel is text first, and any channel can open an [audio room](https://docs.fishcamp.space/docs/guides/audio-rooms) when people want to talk instead. ## Public and private channels | | Public channel | Private channel | | -------------- | --------------------------------- | --------------------------------------- | | Who can see it | Everyone in the workspace | Only the people added to it | | Who can join | Anyone: open it and start writing | Someone already in the channel adds you | | In the sidebar | `#` icon | Lock icon | Anyone in the workspace can create a channel. The person who creates it is its first member. ### Create a channel 1. Choose **+** next to **Channels** in the sidebar, or **+** → **New channel**. 2. Type a **Name** and, if you like, a **Description** of what the channel is for. 3. Under **Who can see it**, choose **Public** or **Private**. 4. Choose **Create channel**. Channel names are lowercase and use hyphens instead of spaces; other punctuation is dropped (`Design crit` becomes `design-crit`, `Release 2.5` becomes `release-25`). They have up to 60 characters and must be unique among the workspace's active channels. Descriptions have up to 250 characters. ### Add people to a private channel 1. Open the channel and choose the gear icon (**Channel settings**). 2. Under **People**, choose someone from the workspace. 3. Choose **Add**. The timeline shows who added whom. Anyone already in a private channel can add other people from the workspace. ### Rename, describe or archive In **Channel settings** → **About**: * **Name**: admins and the person who created the channel can rename it. The timeline records the old and new names. * **Description**: anyone who can see the channel can change it. To archive a channel, an admin opens **Channel settings** → **Archive channel** and confirms. The channel leaves the list, any open audio room closes and its incoming webhooks stop posting. Its messages stay stored, but nobody can open the channel anymore, in the app or through the API, and archiving can't be undone from either. ## Direct messages A direct message is a private conversation between two people in the workspace. There is only ever one per pair: opening it again takes you back to the same conversation. * Choose a person under **Direct messages** in the sidebar, or press Ctrl + K (⌘ + K on a Mac) and type their name. * Direct messages have an audio room, like channels. * You can't add a third person, archive a direct message, or point an incoming webhook at it. ## Sending messages * **Enter** sends. **Shift + Enter** starts a new line. * A message can have up to **4,000 characters**. * Messages are plain text. Links become clickable. * If a message fails to send, it's marked "Not sent" in the timeline and its text goes back into the message box, so you can send it again. ### Mentions To get someone's attention, write `@` followed by their name: ```txt @Maya the build for 2.4 is green, can you check the release notes? ``` A mention is part of the message text: everyone in the channel sees it, and the channel shows as unread for anyone who hasn't opened it since. ### Edit and delete Hover over a message (or focus it with the keyboard) to see its actions. | Action | Who can do it | What happens | | ------------------ | --------------------------------- | ------------------------------------------------------------------------------------- | | **Edit message** | The author, on their own messages | The text changes for everyone and shows "(edited)". **Esc** cancels, **Enter** saves. | | **Delete message** | The author, on their own messages | After you confirm, everyone sees "Message deleted" in its place. | | **Delete message** | Admins and owners, on any message | Same as above. Use it for messages from people, webhooks or API keys. | Messages from webhooks and API keys can't be edited in the app. An [API key](https://docs.fishcamp.space/docs/agents/api-keys) itself can only edit or delete the messages it posted, even when an admin created it. ## Unread and typing * **Unread**: a channel with messages you haven't seen shows in bold with a dot in the sidebar. Opening it marks it as read. * **Typing**: while someone writes, the channel shows "Maya is typing…", "Maya and Theo are typing…", or "Several people are typing…" from three people up. * **In audio**: channels with an open audio room show a headphones icon and how many people are in it. ## Timeline pills Besides messages, the timeline shows short lines (pills) for what happens in the channel, with who did it and when: * the channel was created or renamed, or someone was added to it or joined with an invite link; * voice, camera or screen sharing was turned on or off; * the audio room opened, someone joined or left (guests with "(guest)" after their name), someone started or stopped sharing their screen, and the room closed (with how long it lasted); * transcription started, and where the transcript went; * an incoming webhook was created or revoked. When several people join or leave at once, Fishcamp groups them into one line. ## Presence Your status shows next to your name in the **Direct messages** list of the sidebar, and on your own name at the bottom of the sidebar, where you choose it. | Status | How it's set | | ------------------ | -------------------------------------------------------------------------------------------------------------------------- | | **Online** | The default when you open Fishcamp, or you choose it. If you had chosen Away or Do not disturb, this browser remembers it. | | **Away** | You choose it, or it turns on by itself after Fishcamp has been in a background tab for 5 minutes. | | **Do not disturb** | You choose it. It stays on even while you're in an audio room. | | **In audio** | Automatic while you're in an audio room. | | **Offline** | Automatic when you close Fishcamp. | ## Limits | What | Limit | | ------------------- | -------------------------------------- | | Message length | 4,000 characters | | Channel name | 60 characters, unique in the workspace | | Channel description | 250 characters | | Workspace name | 2 to 60 characters | --- # Audio rooms > Talk, turn on your camera and share your screen inside any channel. Per-channel switches, timeline pills, keyboard shortcuts and browser support. Source: https://docs.fishcamp.space/docs/guides/audio-rooms Every channel and every direct message has an audio room. It lives inside the channel: the conversation keeps going in text while people talk, and everything that happens in the room shows up on the same timeline. ## Join and leave 1. Open the channel and choose **Join audio** at the top right. 2. Allow the microphone when the browser asks. You join with the microphone on and the camera off. 3. Choose **Leave** under the room, or **Leave audio** at the top, when you're done. The first person to join opens the room; when the last person leaves, it closes. You can keep talking while you read other channels: a small bar at the bottom of the sidebar shows which room you're in, with **Mute** and **Open**. The **Audio rooms** list in the sidebar shows every room with people in it. If you join the same room from a second tab, the first tab leaves the room. ## Controls and shortcuts | Control | Shortcut | Notes | | ----------------------------------- | -------- | ----------------------------------------------------------------------------------------- | | **Mute** / **Unmute** | Ctrl + D | Your microphone. | | **Start video** / **Stop video** | Ctrl + E | Your camera. Unavailable when the channel's camera switch is off. | | **Share screen** / **Stop sharing** | | A tab, a window or the whole screen. Unavailable when the channel's screen switch is off. | | **Leave** | | Leaves the room. Your microphone and camera turn off. | On a Mac, use ⌘ instead of Ctrl. The shortcuts work while the room's channel is open. Use the expand button at the top right of the room to make it fill the window, for example while someone shares a screen. ## Voice, Camera and Screen switches Each channel has three switches: **Voice**, **Camera** and **Screen**. All three start on. Change them in **Channel settings** → **Voice, camera and screen** (on wide screens they're also in the channel header). * **Anyone in the channel can change them.** There's no special role for it. * **Every change shows on the timeline**, for example "Maya turned off the camera in this channel", so nothing changes silently. * **Changes apply right away**, also to people already in the room: | Switch turned off | What happens | | ----------------- | --------------------------------------------------------------------------------------------------------- | | **Voice** | The open audio room closes for everyone, and **Join audio** is unavailable until voice is turned back on. | | **Camera** | Cameras in the room stop, and nobody can start video. | | **Screen** | Screen sharing in the room stops, and nobody can share a screen. | ## What shows on the timeline | Pill | When | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | "opened the audio room" | The first person joins. | | "joined the audio room" / "left the audio room" | People come and go. Several at once are grouped into one line. Guests have "(guest)" after their name. | | "started sharing their screen" / "stopped sharing their screen" | Screen sharing starts or stops. | | "Audio room closed · 25 min" | The last person leaves. | | "turned on/off voice, the camera or screen sharing in this channel" | Someone flips a switch. | ## Guests New in November 2026 To bring someone without a Fishcamp account into a call, such as a client, choose **Meeting link** at the top right of the channel and send them the link. They join this channel's audio room with just their name, while someone from your workspace is in it, and they never see the channel's messages. Guests have a **Guest** label in the room and in the list of who's in audio. Anyone in the channel can take one out with the remove icon next to their name. When the last person from your workspace leaves, the meeting ends for the guests too. See [Meeting links for guests](https://docs.fishcamp.space/docs/guides/meeting-links). ## Browser support | | Chrome | Edge | Firefox | Safari | | ---------------------------- | ------ | ---- | ------- | ------ | | Voice and camera | Yes | Yes | Yes | Yes | | Share your screen (desktop) | Yes | Yes | Yes | Yes | | Share sound with your screen | Yes | Yes | No | No | * To share sound, choose a tab (or your entire screen, where the browser offers it) and turn on audio sharing in the browser's picker. * Sharing your screen needs a desktop browser. On a phone you can talk, use your camera and watch a shared screen. ## Troubleshooting Select the lock icon in the address bar, allow the microphone (and the camera if you need it), then reload the page and join again. Close the other app that's using it (another call, for example) and try again. Voice is turned off in this channel. Anyone in the channel can turn it back on in **Channel settings**. The channel's **Camera** or **Screen** switch is off. Turn it on in **Channel settings**, if your team agrees. Someone turned **Voice** off in the channel. The timeline shows who did it. ## Privacy Audio rooms aren't recorded. If the channel has [transcription](https://docs.fishcamp.space/docs/guides/transcription) turned on, everyone in the room sees **Transcribing**, and only the text is kept: the audio is never stored. --- # Meeting links for guests > Bring clients and partners into a channel's audio room with a link, like Zoom. Guests type their name and join, no account needed. Source: https://docs.fishcamp.space/docs/guides/meeting-links New in November 2026 A meeting link lets someone outside your workspace, such as a client or a partner, join a channel's audio room with just their name. They don't need a Fishcamp account, and they only get the call: no messages, no other channels. ## Share a meeting link 1. Open the channel and choose **Meeting link** at the top right. On smaller screens it's the link icon. 2. Choose **Create and copy link**. The link is copied and looks like this: ```txt https://fishcamp.space/m/Hq7kT2mZpX4rNa ``` 3. Send it to your guests by email or chat. Anyone who can see the channel can share its link. A channel has one link at a time: open **Meeting link** again to copy the same one. The panel also shows how many times guests joined with it. Meeting links only work in channels with **Voice** on, and not in direct messages. ## What your guest sees 1. The guest opens the link and sees the channel and the workspace it belongs to. 2. They type their name, choose whether their microphone (and camera, if the channel allows it) starts on, and choose **Join meeting**. 3. If nobody from your workspace is in the room yet, they see **Waiting for the team**. The page checks every few seconds and lets them in as soon as someone from the team joins. In the room, guests can mute, start their camera and share their screen, when the channel's **Camera** and **Screen** switches are on (sharing a screen needs a desktop browser). **Leave** ends their call, and **Rejoin** brings them back. Their browser remembers the name for next time. | Guests can | Guests can't | | ------------------------------------------------------------------------ | ------------------------------------------------------------- | | Talk, and use the camera and screen sharing when the channel allows them | Read or post messages, or see the channel's history | | See and hear everyone in the room | Join, or stay, while nobody from the workspace is in the room | | Join with the mic off and just listen | See other channels, people or anything else in the workspace | ## In the room * Guests have a **Guest** label, on their video and in the sidebar's list of who's in the channel's audio room. * The timeline shows them like anyone else, with "(guest)" after the name, for example "Ana Souza (guest) joined the audio room". * A room takes up to **20 guests** at a time. The next one sees **This meeting is full**. * **When the last person from your workspace leaves, the meeting ends for the guests too.** They see **The meeting ended** and can choose **Join again when they're back**. * Fishcamp checks each guest again as they connect to the room: someone from the team must be there, the link must still be on, and the room must have space. ## Transcription If the channel has [transcription](https://docs.fishcamp.space/docs/guides/transcription) on, guests see this before they join: "This room is transcribed: what you say becomes text for the team. Audio is never stored." In the room they see a **Transcribed** label. What a guest says is transcribed from their own microphone, like everyone else's, and their lines carry their name with "(guest)", for example `Ana Souza (guest)`. When a guest spoke, Dcision never sends the transcript to your [AI agents](https://docs.fishcamp.space/docs/agents) on its own: the transcript waits for someone from your team to confirm (**Needs a decision**). Saving it to OpenSync or discarding it works as usual. ## Remove a guest Anyone who can see the channel can take a guest out of the call: 1. In the sidebar, find the guest in the list of who's in the channel's audio room. 2. Choose the remove icon next to their name (**Remove from the room**). The guest sees "You were removed from the meeting". The link still works, so they could open it again: to keep them out, make a new link (that also takes the other guests out of the room). ## New link or turn it off In the **Meeting link** panel: * **New link** replaces the link and copies the new one. The old one stops working. * **Turn off link** turns it off, after you confirm ("Turn it off? Guests in the room leave now."). Guests who open it see "This meeting link was turned off". Both also take the guests who are in the room out of it. Send the new link to the guests who should come back. ## What guests might see | The guest sees | Why | What to do | | ------------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------ | | **Waiting for the team** | Nobody from the workspace is in the room. | Join the audio room. The guest gets in a few seconds later. | | **The meeting ended** | Everyone from the workspace left the room. | Join the audio room again; the guest chooses **Join again when they're back**. | | **This meeting link was turned off** | Someone chose **New link** or **Turn off link**. | Send them the current link. | | **This room isn't open right now** | The channel's voice is off, or the channel was archived. | Turn **Voice** back on, or share a link from another channel. | | **This meeting is full** | 20 guests are already in the room. | Remove a guest, or ask one to leave. | | **We couldn't find this meeting** | The link is incomplete or has a typo. | Send the link again. | | **We couldn't open this meeting** | The page couldn't reach Fishcamp. | The guest chooses **Try again**. | ## Good to know * **The link is the key.** Anyone who has it can join while your team is in the room. Share it with the people you're meeting, not in public places. * **Guests don't join the workspace.** To bring someone in for good, send an [invite link](https://docs.fishcamp.space/docs/guides/people-and-roles#invite-links) instead. * **Turning off Voice** closes the room for everyone, guests included. **New link** needs voice on, too. * **To build your own guest client**, see [Meeting link endpoints](https://docs.fishcamp.space/docs/api/meeting-links). --- # People and roles > Invite people with a link or by email, and choose what they can do. Owner, Admin and Member, who can change roles, and why the last owner can't leave. Source: https://docs.fishcamp.space/docs/guides/people-and-roles Everyone in a workspace has one role: **Owner**, **Admin** or **Member**. The person who creates the workspace is its first owner. ## What each role can do | Action | Member | Admin | Owner | | ------------------------------------------------------------------------------------------------ | :----: | :---: | :---: | | Read and write in public channels, create channels, send direct messages | Yes | Yes | Yes | | Join audio rooms and change a channel's Voice, Camera and Screen switches | Yes | Yes | Yes | | Share a channel's meeting link with guests, and remove guests from the room New in November 2026 | Yes | Yes | Yes | | Choose where a transcript goes | Yes | Yes | Yes | | Delete their own messages | Yes | Yes | Yes | | Delete anyone's messages (as a person: an API key never can) | | Yes | Yes | | Rename channels (members can rename the channels they created) | | Yes | Yes | | Archive channels (as a person: an API key never can) | | Yes | Yes | | Create and revoke incoming webhooks | | Yes | Yes | | Create and revoke API keys | | Yes | Yes | | Add, change and delete AI agents New in November 2026 | | Yes | Yes | | Connect and disconnect integrations such as OpenSync New in November 2026 | | Yes | Yes | | Invite people as Member or Admin, and revoke invites | | Yes | Yes | | Create and turn off invite links New in November 2026 | | Yes | Yes | | Change roles between Member and Admin, remove members and admins | | Yes | Yes | | Promote someone to Owner, change an owner's role, remove an owner | | | Yes | Admin powers belong to the person. An [API key](https://docs.fishcamp.space/docs/agents/api-keys) never has them, even one an admin created: it can't delete other people's messages, rename or archive channels, invite people, change roles or manage webhooks, API keys, AI agents and integrations. Admins manage the channels they can see: to rename or archive a private channel, to give it a webhook or an AI agent, or to create an invite link to it, they need to be in it. ## Invite people There are two ways to invite people: an invite link that anyone can use, or an email invite for one address. ### Share an invite link New in November 2026 1. Choose **+** → **Invite people**. To invite people straight to a channel, open the channel and choose the person icon at the top right (**Invite people to #channel**, on wider screens). 2. Copy the link under **Send an invite link** and share it in a chat or an email. 3. To change it, choose **Edit invite link**, pick **Expire after** (1 day, 7 days, 30 days or Never) and **Max uses** (no limit, or 1 to 100 uses), then **Generate a new link**. How invite links work: * **Anyone with the link can join** as a **Member**. They sign in first with Google or an email code; people new to Fishcamp get an account that way. * **A link to a channel** also adds them to that channel, even a private one, and opens it. The timeline shows that they "joined with an invite link". * **By default a link lasts 30 days, with no limit of uses.** Opening **Invite people** again shows the same link while it still has almost all of its time (and, with a limit of uses, before anyone used it), so you don't pile up links. * **People already in the workspace** who open a link are just taken there, and it doesn't count as a use. The exception is a channel link with a limit of uses: joining the channel through it uses one. * **People** → **Invite links** lists the active links, with their expiry and uses. Links to a private channel only show up for admins who are in it. **Turn off** stops a link at once. Expired, used-up and turned-off links show "Ask the person who sent it for a new one". ### Invite by email 1. Choose **People** in the sidebar. 2. Under **Invite someone**, type the email and choose **Member** or **Admin**. 3. Choose **Send invite**. **Invite people** also has **Or invite by email**, which invites as **Member**. How email invites work: * The invite is **tied to the email address**: only someone signed in with that address can accept it. * The link is **valid for 7 days**. Inviting the same address again sends a fresh link for another 7 days, and the previous link stops working. * If the email doesn't go out, Fishcamp shows the link so you can send it yourself. You can also copy it after a successful send. * The person accepts by opening the link, or from **Invites for you** after signing in to Fishcamp with the invited address. * **Pending invites** lists invites not yet accepted, with their expiry date. **Revoke** cancels one. You can't invite someone who is already in the workspace. To make someone an owner, invite them first, then change their role. ## Change a role or remove someone In **People** → **Members**, admins see a role menu and **Remove** next to each person: * Admins can switch people between **Member** and **Admin**, and remove members and admins. * **Only owners can promote someone to Owner**, change another owner's role or remove an owner. * Removing someone takes them out of every channel of the workspace. Their past messages stay. If they're in an audio room at that moment, they stay connected until they leave it. ## The last owner A workspace always has at least one owner. If you're the only owner, you can't leave the workspace, be removed or be changed to another role. Promote someone else to **Owner** first. --- # Incoming webhooks > Post alerts and updates into a channel from Uptime Kuma, Dokploy, CI jobs or any script, using Discord-compatible payloads. Source: https://docs.fishcamp.space/docs/guides/incoming-webhooks An incoming webhook is a secret URL that posts messages into one channel. It accepts the same JSON as a Discord webhook, so tools that already send alerts to Discord (Uptime Kuma, Dokploy and many others) can send them to Fishcamp instead, without code. ## Create a webhook Admins create webhooks, one channel at a time: 1. Open the channel that should receive the messages, for example `#alerts`. 2. Choose the gear icon (**Channel settings**) and go to **Incoming webhooks**. 3. Type a name, for example `Uptime Kuma`, and choose **Create**. 4. Copy the URL right away. **It's shown only once**: Fishcamp keeps only a hash of it. The URL looks like this: ```txt https://api.fishcamp.space/api/v1/webhooks/inbound/whk_… ``` The channel's timeline shows who created the webhook. The list under **Incoming webhooks** shows each webhook's name, the last characters of its token and when it was last used. Anyone with the URL can post to the channel. Keep it in a secret or an environment variable, never in a public repository. If it leaks, choose **Revoke** next to the webhook and create a new one: the old URL stops working at once. Webhooks post to channels only, not to direct messages. ## Send a message Send a `POST` with a JSON body to the URL. No other authentication is needed. ```bash curl -X POST "https://api.fishcamp.space/api/v1/webhooks/inbound/whk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"content": "Nightly backup finished in 4 min."}' ``` With a card (an "embed"), the same way Discord does it: ```bash curl -X POST "https://api.fishcamp.space/api/v1/webhooks/inbound/whk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "username": "Deploy bot", "content": "Production deploy finished.", "embeds": [{ "title": "web 2.4.1 is live", "url": "https://example.com/releases/2.4.1", "description": "Build 812, 3 commits since 2.4.0.", "color": 5763719, "fields": [ { "name": "Environment", "value": "production", "inline": true }, { "name": "Duration", "value": "2 min 41 s", "inline": true } ], "footer": { "text": "GitHub Actions" }, "timestamp": "2026-11-05T14:32:00Z" }] }' ``` Slack-style payloads with `text` work too: ```bash curl -X POST "https://api.fishcamp.space/api/v1/webhooks/inbound/whk_YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"text": "Disk usage on db-1 is above 85%."}' ``` The message appears under the webhook's name (or the `username` you sent) with an **App** label. ## Payload Send `content` (or `text`), at least one embed, or both. | Field | Type | Description | | ------------ | ------ | ------------------------------------------------------------------------------------------------------------- | | `content` | string | Message text, up to 4,000 characters. | | `text` | string | Slack-style alternative to `content`, used when `content` is missing. Up to 4,000 characters. | | `username` | string | Name shown for this message, up to 80 characters. Defaults to the webhook's name. | | `avatar_url` | string | Accepted for compatibility but not shown: loading images from other sites would reveal readers' IP addresses. | | `embeds` | array | Up to 10 cards, described below. | Each embed: | Field | Type | Description | | ------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `title` | string | Up to 256 characters. | | `description` | string | Up to 4,096 characters. | | `url` | string | An http(s) link, up to 2,048 characters. Makes the title a link. | | `color` | integer | Color of the bar on the left, as a decimal number from 0 to 16777215 (`0xFFFFFF`). For example `5763719` is green (`#57F287`) and `15548997` is red (`#ED4245`). | | `fields` | array | Up to 25 items `{ "name", "value", "inline" }`: `name` up to 256 characters, `value` up to 1,024, `inline: true` puts fields side by side on wide screens. | | `footer` | object | `{ "text" }`: small text under the card, up to 2,048 characters. | | `timestamp` | string | An ISO 8601 date, shown next to the footer in the reader's local time. | Text is shown as plain text and links become clickable. Markdown and Discord-only syntax, such as `**bold**` or `` timestamps, appear exactly as sent. Unknown fields are ignored. ## Responses | Status | When | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `204 No Content` | The message was posted. This is the default answer, as on Discord. | | `200 OK` | The message was posted and you called the URL with `?wait=true`. The body has the message ID and the channel ID. | | `404 Not Found` | The token is unknown or revoked, or the channel was archived. | | `413 Payload Too Large` | The body is larger than 512 KB. | | `415 Unsupported Media Type` | The body isn't JSON. Send it with `Content-Type: application/json`. | | `422 Unprocessable Entity` | Nothing to post, or a field breaks a limit. `details.errors` lists the fields. | | `429 Too Many Requests` | More than 60 requests in a minute from the same address. Wait and retry. | With `?wait=true`: ```bash curl -X POST "https://api.fishcamp.space/api/v1/webhooks/inbound/whk_YOUR_TOKEN?wait=true" \ -H "Content-Type: application/json" \ -d '{"content": "Ping"}' ``` ```json { "id": "6c1f0f0e-8a7e-4f4e-9d55-0e6f6f1a2b3c", "channel_id": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10" } ``` Errors use the API's [error format](https://docs.fishcamp.space/docs/api#errors): ```json { "error": { "code": "validation_error", "message": "Send \"content\" or \"embeds\"", "requestId": "req_5f0c2a9b1e7d4c3a" } } ``` ## Uptime Kuma 1. In Uptime Kuma, open **Settings** → **Notifications** → **Setup Notification**. 2. Set **Notification Type** to **Discord**. 3. Paste the Fishcamp URL into **Discord Webhook URL**. 4. Optionally set **Bot Display Name** (it becomes the message's name) and a **Prefix Custom Message** (it becomes the message text above the card). If your version asks where to post, keep **Send to channel**. 5. Choose **Test**: a test message should appear in the channel. Then choose **Save** and enable the notification on your monitors. When a monitor goes down or comes back up, Uptime Kuma sends a card with the monitor's name, its URL and the time, plus the error (when down) or the response time (when back up), colored red or green. ## Dokploy 1. In Dokploy, open **Settings** → **Notifications** and add a notification. 2. Choose **Discord** and paste the Fishcamp URL as the webhook URL. 3. Pick the events you want, for example app deploys, build errors and database backups. 4. Use the test button to check that a message arrives, then save. Dokploy sends a card with the project, the application and a link to the build. Its dates use Discord's `` timestamp syntax, which Fishcamp shows as plain text; the card's own time is shown normally. ## From your own scripts and CI Anything that can send an HTTP request can post. For example, a GitHub Actions step that reports a job's result (store the URL as a repository secret): ```yaml - name: Report to #deploys if: always() env: FISHCAMP_WEBHOOK_URL: ${{ secrets.FISHCAMP_WEBHOOK_URL }} run: | curl -sS -X POST "$FISHCAMP_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d "{\"content\": \"${{ github.workflow }} on ${{ github.ref_name }}: ${{ job.status }}\"}" ``` GitHub's own repository webhooks send GitHub's event format, which Fishcamp doesn't read. Post from a workflow step, as above, instead. To post as a person or an AI agent instead of an app, use an [API key](https://docs.fishcamp.space/docs/agents/api-keys) with the [REST API](https://docs.fishcamp.space/docs/api/messages) or the [MCP server](https://docs.fishcamp.space/docs/agents/mcp). --- # Transcription and AI routing > Turn on transcription per channel, see who said what, and let an AI decide whether a conversation goes to OpenSync, to the channel's agents or nowhere. Source: https://docs.fishcamp.space/docs/guides/transcription New in November 2026 Transcription writes down what's said in a channel's audio room, line by line, with the name of who said it. When the room closes, an AI reads the transcript and decides where it should go, so decisions and incidents discussed out loud don't get lost. ## Turn it on Transcription is a switch on each channel: 1. Open the channel and choose the gear icon (**Channel settings**). 2. Turn on **Transcription**. From then on, every audio room in that channel is transcribed. Turn the switch off to stop. ## What happens during the call * **Only while people are in the audio room.** Nothing is transcribed when the room is empty, and text messages are never part of the transcript. * **Everyone knows.** While transcription runs, everyone in the room sees a **Transcribing** indicator. * **Live captions.** People in the room see what's said as it's transcribed. **Hide captions** turns them off on your screen. * **One microphone, one speaker.** Each person's microphone is transcribed separately, so every line has the right name on it, even when people talk over each other. * **Guests too.** People who join with a [meeting link](https://docs.fishcamp.space/docs/guides/meeting-links) are told the room is transcribed before they join. Their lines carry their name with "(guest)", for example `Ana Souza (guest)`. * **Your last words count.** What you say right before you leave is still sent: Fishcamp accepts it for up to 18 seconds after you leave. * **Audio is never stored.** Fishcamp turns speech into text as it happens and keeps only the text. ## Where the transcript goes About 20 seconds after the audio room closes, once everyone's last words are in, the transcript is sent to a [Dcision](https://dcision.io) decision: an AI that answers one question about it ("what should happen to this conversation?") and says how confident it is. It picks exactly one action: | Action | Chosen for | What happens | | -------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------- | | **Discard** | Small talk, quick check-ins, nothing to keep | Nothing is sent anywhere. | | **Save to OpenSync** | Decisions, specs, procedures, meeting notes | The transcript is saved as a note in the workspace's [OpenSync space](https://docs.fishcamp.space/docs/guides/opensync). | | **Send to agents** | Infra work, incidents, deploys | The whole transcript goes to the channel's [AI agents](https://docs.fishcamp.space/docs/agents) that take transcript handoffs. | **When the AI isn't sure, it doesn't guess.** Low-confidence results wait for a person to choose (**Needs a decision**). So does a transcript in which a guest spoke, when Dcision picks **Send to agents**: someone from the team confirms before it reaches any agent. A room that closes with nothing transcribed is discarded on its own. The timeline gets a line with the result, for example "Dcision saved the transcript to OpenSync (91% sure): Fishcamp/Meeting notes/2026-11-05 14-00 UTC · #planning.md". ### Save to OpenSync The transcript is saved as a Markdown note in the OpenSync space that an admin connected in **Settings** → **Integrations**, in the folder chosen there (`Fishcamp/Meeting notes` by default). See [OpenSync integration](https://docs.fishcamp.space/docs/guides/opensync). If the workspace hasn't connected OpenSync, nothing leaves Fishcamp: the transcript shows **Failed**, and the timeline says "OpenSync isn't connected to this workspace yet. An admin can connect it in Settings → Integrations". ### Send to agents Fishcamp delivers the whole transcript (who spoke, every line with guests marked, the decision and a link to the channel) to the active agents in this channel that have **Transcript handoffs** turned on, as long as each agent's key is active and the admin who added it is still in the workspace and can see the channel. Each agent can then answer in the channel with its own key. The payload is described in [Agent webhooks → `transcript.handoff`](https://docs.fishcamp.space/docs/agents/webhooks#transcript-handoff). * The timeline says which agents got it, for example "Maya Lopes sent the transcript to Scout.", and which didn't answer. * If no agent in the channel takes handoffs, the action fails with "no agent in #design-crit takes transcript handoffs yet". An admin can add one in **AI agents** and turn on **Transcript handoffs**. * Each agent gets one attempt. If all of them fail, the transcript shows **Failed**, with **Try again**. ## Change the decision Open the transcript: choose **View transcript** on its line in the timeline, or find it under **Transcripts** in **Channel settings**. It shows each line with its speaker, where the transcript went, who decided and how sure Dcision was. * **Waiting for a decision:** choose **Save to OpenSync**, **Send to agents** or **Discard**. * **Already decided:** choose **Change destination**, then one of the actions, for example **Send to agents again**. * **Failed:** **Try again** repeats the action that failed. What already left Fishcamp stays where it went: discarding a transcript that was saved to OpenSync leaves the note in OpenSync. You can choose only once Dcision has decided, or after it failed, and only if something was transcribed. Anyone who can see the channel can choose; if two people choose at the same time, only the first choice goes through. ## Read transcripts from code Transcripts are also available to API keys with the `read` scope: * REST: [`GET /channels/:id/transcripts` and `GET /transcripts/:id`](https://docs.fishcamp.space/docs/api/transcripts). People can also choose a destination with [`POST /transcripts/:id/route`](https://docs.fishcamp.space/docs/api/transcripts#route-a-transcript). * MCP: the `list_transcripts` and `read_transcript` tools, for example from Claude Code ("summarize yesterday's call in #platform"). See [MCP server](https://docs.fishcamp.space/docs/agents/mcp). * Webhooks: agents can get each decision (`transcript.routed`) and whole transcripts (`transcript.handoff`). See [Agent webhooks](https://docs.fishcamp.space/docs/agents/webhooks). ## Good to know * Tell people a channel is transcribed before you start; the indicator helps, but saying it is better. * Keep transcription on in channels where decisions happen (planning, incidents, client calls) and off in social channels. * Before you rely on **Save to OpenSync** or **Send to agents**, connect OpenSync and add an agent to the channel. --- # OpenSync integration > Connect your workspace to an OpenSync space so transcripts are saved there as meeting notes. Set it up in Settings → Integrations. Source: https://docs.fishcamp.space/docs/guides/opensync New in November 2026 [OpenSync](https://opensync.space) is where teams keep specs, decisions and notes. Connect a space to your workspace, and transcripts worth keeping are saved there as Markdown notes: when Dcision picks **Save to OpenSync**, or when someone chooses it on a transcript. See [Transcription and AI routing](https://docs.fishcamp.space/docs/guides/transcription#routing). Each workspace connects its own space. Nothing is shared between workspaces. ## Before you start * You need to be an admin of the workspace. * In OpenSync, open the space, go to its **Connect** panel and create a token. It starts with `osk_` and only gives access to that space. ## Connect a space 1. Go to **Settings** → **Integrations**: choose **People** in the sidebar, then the **Integrations** tab. 2. Under **OpenSync**, fill in: * **Space link or ID**: open the space in OpenSync and copy the address from the browser (`https://opensync.space/spaces/…`), or type just its ID. * **Space token**: the `osk_…` token. * **Folder for meeting notes**: where notes go inside the space. Default: `Fishcamp/Meeting notes`. 3. Choose **Connect OpenSync**. Fishcamp checks the token with OpenSync before it saves anything. If OpenSync refuses, you see why, for example "OpenSync didn't accept the token for this space" or "OpenSync couldn't find this space". If OpenSync doesn't answer, you see "Couldn't reach OpenSync". Either way, nothing is saved. If your team uses OpenSync at another address, choose **Use a different OpenSync address** and type it, for example `https://api.opensync.space`. It must be a public `https` address without a path. ## What gets saved One Markdown note per audio room session, named after the time the room opened (UTC) and the channel: ```txt Fishcamp/Meeting notes/2026-11-05 14-00 UTC · #planning.md ``` ```md # 2026-11-05 14:00 UTC · #planning Participants: Maya Lopes, Theo Park, Ana Souza (guest) Source: Fishcamp audio room transcript (audio not stored). ## Transcript - **14:02 Maya Lopes:** Let's ship the pricing page on Friday. - **14:03 Theo Park:** I will update the copy by Thursday. ``` * Calls in a direct message are named `direct call` instead of the channel. * Saving the same transcript again writes to the same note. * The transcript then shows "Saved as" with the note's path, and the timeline says who saved it and where. ## Check, edit or disconnect Once connected, the **OpenSync** card shows the space, the folder, the last characters of the token and when a note was last saved. * **Test connection** asks OpenSync again and tells you whether it works. * **Edit** changes the space, the folder or the address. To change only the folder, leave **Space token** blank to keep the current token. To change the space or the address, paste the token again: the stored token only works with the space and address it was saved for. * **Disconnect**, after you confirm, removes the connection and the stored token. Transcripts stop going to OpenSync; notes already saved stay there. If a save fails, the card says **Connected, but the last attempt failed**, with the reason. ## When OpenSync isn't connected If **Save to OpenSync** is chosen while the workspace has no connection, nothing leaves Fishcamp. The transcript shows **Failed**, and the timeline says: "OpenSync isn't connected to this workspace yet. An admin can connect it in Settings → Integrations". After connecting, open the transcript and choose **Try again**. ## Security * **The token is stored encrypted.** The API never returns it: admins see only its last four characters, and everyone else only sees whether OpenSync is connected. * **One space only.** The token can't reach other spaces, and Fishcamp only calls public `https` addresses, checked on every call. * **People only.** Only admins connect, test and disconnect, and requests made with an API key are refused. To do this from code, see [Integration endpoints](https://docs.fishcamp.space/docs/api/integrations). --- # AI agents > Add AI agents to your workspace. Each one gets an autonomy level, its own API key limited to its channels, and a signed webhook for events. Source: https://docs.fishcamp.space/docs/agents New in November 2026 An AI agent is a teammate made of software: a bot, a Claude Code session, your DevOps squad. In Fishcamp, each agent has a name, a job and the channels it works in. It reads and posts with its own API key, and it can get events from those channels at a webhook URL you choose. Everything an agent posts shows its name and an **AI** label. ## Add an agent Admins add agents: 1. In the sidebar, choose **+** next to **AI agents**. Or open **AI agents** and choose **Add agent**. 2. Under **About**, fill in: * **Name**: up to 40 characters, unique in the workspace. It's shown on everything the agent posts. * **What it does**: up to 120 characters, for example `Turns meeting decisions into tasks`. * **How it should work** (optional): up to 4,000 characters on what to look for, when to speak up and what to leave alone. 3. Under **What it can do**, choose how much it can do on its own and the **Channels it follows**: public channels, or private channels you're in. 4. Under **Webhook** (optional), type a public `https` address and pick the **Events it receives**. 5. Choose **Create agent**. Fishcamp then shows the agent's **API key** (`cwk_…`) and, if you added a webhook, its **Webhook signing secret** (`whsec_…`). **Copy both right away: they're shown only once.** If you lose them, rotate them on the agent's page. ## How much it can do on its own | In the app | API value | Its API key | | ----------------------------------------------------- | -------------- | ---------------------------------- | | **Observes**: reads, never posts | `OBSERVE` | `read` only. Fishcamp enforces it. | | **Proposes**: posts suggestions for people to act on | `PROPOSE` | `read` and `write` | | **Acts on simple things**: posts and creates channels | `ACT_LOW_RISK` | `read` and `write` | | **Autonomous**: acts on its own inside its channels | `AUTONOMOUS` | `read` and `write` | The level goes to the agent with every event, and the agent is expected to follow it. Fishcamp only enforces **Observes**: with the other levels, the key can post messages and create channels, and your agent decides how much to do. Changing the level changes the key's scopes right away. ## The channels it works in An agent works only in the channels you pick, public or private: * **Its key only reaches those channels**, in the [REST API](https://docs.fishcamp.space/docs/api) and the [MCP server](https://docs.fishcamp.space/docs/agents/mcp). Every other channel, direct messages included, answers "not found", as if it didn't exist. * **A channel the agent creates with its key is added to its channels**, so it can keep working there. * **Events only come from those channels.** With no channels, its webhook gets nothing. The agent never reaches the other channels of the admin who added it, but it does depend on that admin (or on whoever last rotated its key): * **An admin can only add an agent to channels they can see**: public channels, or private channels they're in. * **If that admin leaves a private channel**, the agent stops getting events from it and its key can't reach it. * **If that admin leaves the workspace**, the key stops working and events stop. Another admin can rotate the key to issue a new one. More in [API keys](https://docs.fishcamp.space/docs/agents/api-keys#agent-keys). When an agent gets the new messages of a channel, the message box there says so, for example "Scout gets new messages here". ## Webhook and events With a webhook URL, Fishcamp sends a signed `POST` for each event you picked, from the agent's channels: | Event | In the app | When | | -------------------- | ----------------------- | ---------------------------------------------------------------------------------- | | `message.created` | **New messages** | Someone posts a message. The agent's own messages aren't sent back to it. | | `audio.started` | **Audio room opened** | The first person joins the audio room. | | `audio.ended` | **Audio room closed** | The last person leaves the audio room. | | `transcript.routed` | **Transcript decided** | A transcript was saved, sent or discarded, or is waiting for a person. | | `transcript.handoff` | **Transcript handoffs** | Dcision or a person sends a transcript to agents. It carries the whole transcript. | Headers, signatures, retries and every payload are in [Agent webhooks](https://docs.fishcamp.space/docs/agents/webhooks). Without a webhook, the agent still works through the MCP server or the REST API with its key. ## Transcript handoffs When a transcript's destination is **Send to agents**, Fishcamp delivers the whole transcript to the active agents in that channel that have **Transcript handoffs** turned on. The timeline says who got it, for example "Maya Lopes sent the transcript to Scout." If no agent in the channel takes handoffs, the action fails and says so. See [Transcription and AI routing](https://docs.fishcamp.space/docs/guides/transcription#send-to-agents). ## Manage an agent Choose the agent under **AI agents** to open its page: * **Status** shows **Active**, **Paused** or **Failing** (the last delivery failed), how many deliveries worked and failed, and the last one. * **Send test event** sends a `ping` to the webhook and shows the result, for example "Delivered · HTTP 200". * **Pause** stops events until someone chooses **Resume**. The API key keeps working. * **Rotate API key**: the old key stops working right away. * **Rotate webhook secret**: deliveries are signed with the new secret right away. * **Delete agent**: its API key stops working and it gets no more events. What it posted stays in the channels. Everyone in the workspace can see the agents. Members who aren't admins don't see the webhook URL, the details of a failed delivery or channels they can't see. Only admins change agents, and always as a person: API keys can't read, create or change agents. To do it from code, see [Agent endpoints](https://docs.fishcamp.space/docs/api/agents). ## Next steps --- # Agent webhooks > How Fishcamp delivers events to an AI agent's webhook. Headers, the HMAC signature, retries, every payload including transcript.handoff, and replies. Source: https://docs.fishcamp.space/docs/agents/webhooks New in November 2026 When an [AI agent](https://docs.fishcamp.space/docs/agents) has a webhook URL, Fishcamp sends it a `POST` with a JSON body for each event it subscribes to, from the channels it works in. This page is the reference for those requests. For a working example, see [Build your first agent](https://docs.fishcamp.space/docs/tutorials/build-your-first-agent). ## Delivery * **Answer with a 2xx within 10 seconds.** Do the work after answering: for example, answer `202` and handle the event in the background. * **Retries.** A failed delivery is tried up to three times: right away, 2 seconds later and 8 seconds after that. Answers in the 4xx range aren't retried, except `429`. * **Transcript handoffs are tried once**, when the transcript is sent, and the result shows on the timeline. * **Redirects aren't followed.** Point the webhook at the final address. * **Public `https` only.** The URL must use `https`, have no user or password, and lead to a public address. Fishcamp checks the address as it connects, on every delivery, so a DNS change can't send a delivery into a private network. * **Order isn't guaranteed**, and after a retry the same event can arrive twice. Use `X-Fishcamp-Delivery` to skip repeats. * **Paused agents get nothing**, and neither do agents without channels. * **The admin behind the agent counts.** An agent only gets events while its key is active and the admin who added it (or last rotated its key) is still in the workspace and can see the channel. If that admin leaves the workspace or the private channel, deliveries from it stop. The agent's page shows how many deliveries worked and failed, the last HTTP status and the last error. ## Headers | Header | Value | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Fishcamp-Webhooks/1.0` | | `X-Fishcamp-Event` | The event name, for example `message.created`. | | `X-Fishcamp-Delivery` | The delivery's ID, the same as `id` in the body. Retries keep it. | | `X-Fishcamp-Timestamp` | When this attempt was sent, in Unix seconds. | | `X-Fishcamp-Signature` | `v1=` followed by the HMAC-SHA256, in hex, of the timestamp, a dot and the raw body, keyed with the agent's webhook secret (`whsec_…`). | ## Check the signature Check every request before you trust it: 1. Read the raw body as a string, before you parse the JSON. 2. Compute the HMAC-SHA256 of `X-Fishcamp-Timestamp`, a dot (`.`) and the raw body, with the webhook secret as the key (the whole string, `whsec_` included). 3. Compare `v1=` plus the hex digest with `X-Fishcamp-Signature`, in constant time. 4. Refuse timestamps more than 5 minutes away from your clock, so an old request can't be replayed. In Node.js: ```js import { createHmac, timingSafeEqual } from 'node:crypto'; // rawBody: the request body exactly as received (a string, before JSON.parse) export function isFromFishcamp(rawBody, headers, secret) { const timestamp = headers['x-fishcamp-timestamp'] ?? ''; const signature = headers['x-fishcamp-signature'] ?? ''; if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } ``` In Python: ```python import hashlib import hmac import time def is_from_fishcamp(raw_body: bytes, headers, secret: str) -> bool: # raw_body: the body as received; headers: case-insensitive, as in Flask, Django or FastAPI timestamp = headers.get("X-Fishcamp-Timestamp", "") signature = headers.get("X-Fishcamp-Signature", "") if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: return False signed = timestamp.encode() + b"." + raw_body expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) ``` The secret is shown once, when you create the agent or rotate the secret. A new secret signs deliveries right away, so update your agent before the next event. ## The envelope Every event has the same outer shape: ```json { "id": "5b0c9e8a-2f4d-4c1b-9a7e-3d6f8b1c2e4a", "event": "message.created", "sentAt": "2026-11-05T18:00:00.000Z", "workspace": { "id": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "name": "Northstar Studio", "slug": "northstar-studio" }, "channel": { "id": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "name": "design-crit", "kind": "STANDARD" }, "agent": { "id": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "name": "Scout", "autonomy": "PROPOSE" }, "data": {} } ``` | Field | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | The delivery's ID, also sent as `X-Fishcamp-Delivery`. | | `event` | The event name. | | `sentAt` | When Fishcamp created the event. | | `workspace`, `channel` | Where it happened. | | `agent` | Which agent it's for, with its [autonomy level](https://docs.fishcamp.space/docs/agents#autonomy): useful when one server runs several agents. In `transcript.handoff` it also has the agent's `instructions`. | | `data` | The event itself, described below. | ## Events ### `message.created` Someone posted a message in one of the agent's channels: a person or another API key. The agent's own messages aren't sent back to it. `data.message` is a [message object](https://docs.fishcamp.space/docs/api/messages#message-object). ```json { "message": { "id": "6c1f0f0e-8a7e-4f4e-9d55-0e6f6f1a2b3c", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "kind": "TEXT", "eventType": null, "authorType": "MEMBER", "author": { "id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes", "email": "maya@northstar.studio", "avatarUrl": null }, "authorName": null, "authorAvatarUrl": null, "content": "@Scout what do we still need for the launch?", "payload": null, "embeds": [], "createdAt": "2026-11-05T18:00:00.000Z", "editedAt": null, "deletedAt": null, "clientId": null } } ``` Messages from other agents have `"authorType": "AGENT"`. Skip them unless you mean to answer, or two agents can answer each other forever. ### `audio.started` The first person joined the channel's audio room. ```json { "mediaSessionId": "e4d3c2b1-a0f9-4e8d-b7c6-5a4b3c2d1e0f", "startedBy": "Maya Lopes" } ``` ### `audio.ended` The last person left and the room closed. `participants` lists everyone who was in it, with guests marked "(guest)". ```json { "mediaSessionId": "e4d3c2b1-a0f9-4e8d-b7c6-5a4b3c2d1e0f", "durationMs": 1528000, "participants": ["Maya Lopes", "Theo Park", "Ana Souza (guest)"] } ``` ### `transcript.routed` A transcript's destination was decided, by Dcision or by a person, or it's waiting for a person. `data.transcript` is the transcript as in [`GET /transcripts/:id`](https://docs.fishcamp.space/docs/api/transcripts#transcript-object), without the lines, and `data.note` is the sentence shown on the timeline. ```json { "transcript": { "id": "3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "startedAt": "2026-11-05T15:00:12.000Z", "endedAt": "2026-11-05T15:25:40.000Z", "status": "DONE", "action": "save_to_opensync", "confidence": 0.91, "routedBy": "dcision", "routedAt": "2026-11-05T15:25:52.000Z", "segmentCount": 48, "participants": ["Maya Lopes", "Theo Park"], "result": { "executionId": "exe_8f2c1a", "opensync": { "path": "Fishcamp/Meeting notes/2026-11-05 15-00 UTC · #design-crit.md", "spaceId": "space-123" } } }, "note": "Dcision saved the transcript to OpenSync (91% sure): Fishcamp/Meeting notes/2026-11-05 15-00 UTC · #design-crit.md" } ``` ### `transcript.handoff` Dcision or a person chose **Send to agents** for a transcript. Fishcamp sends the whole transcript to the active agents of that channel that have **Transcript handoffs** turned on: up to five, the oldest first, one attempt each, all at the same time. When a guest spoke in the meeting, Dcision never sends it on its own: a person confirms first. ```json { "id": "a3b4c5d6-e7f8-4a9b-8c0d-1e2f3a4b5c6d", "event": "transcript.handoff", "sentAt": "2026-11-05T15:26:03.000Z", "workspace": { "id": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "name": "Northstar Studio", "slug": "northstar-studio" }, "channel": { "id": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "name": "design-crit", "kind": "STANDARD" }, "agent": { "id": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "name": "Scout", "autonomy": "PROPOSE", "instructions": "Suggest a task only when someone agrees on an owner and a date." }, "data": { "transcript": { "id": "3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "startedAt": "2026-11-05T15:00:12.000Z", "endedAt": "2026-11-05T15:25:40.000Z", "participants": ["Maya Lopes", "Theo Park", "Ana Souza (guest)"], "text": "Maya Lopes: Let's ship the pricing page on Friday.\nTheo Park: I will update the copy by Thursday.\nAna Souza (guest): Send me the preview link when it's ready.", "segments": [ { "speakerName": "Maya Lopes", "startMs": 4200, "text": "Let's ship the pricing page on Friday.", "guest": false }, { "speakerName": "Theo Park", "startMs": 9800, "text": "I will update the copy by Thursday.", "guest": false }, { "speakerName": "Ana Souza (guest)", "startMs": 15400, "text": "Send me the preview link when it's ready.", "guest": true } ] }, "decision": { "action": "run_devops_agent", "routedBy": "person", "confidence": null, "executionId": null, "requestedBy": { "id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes" } }, "link": "https://fishcamp.space/w/northstar-studio/c/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "reply": { "method": "POST", "path": "/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/messages", "auth": "Bearer " } } } ``` | Field | Description | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data.transcript.id` | The transcript's ID. Read it again with [`GET /transcripts/:id`](https://docs.fishcamp.space/docs/api/transcripts#read-a-transcript) or the MCP tool [`read_transcript`](https://docs.fishcamp.space/docs/agents/mcp#read_transcript). | | `data.transcript.startedAt`, `endedAt` | When the audio room opened and closed. | | `data.transcript.participants` | Everyone who spoke, with guests marked "(guest)". | | `data.transcript.text` | The conversation, one `Name: text` line per line. Past 20,000 characters, it keeps the first and the last 10,000, with `[…]` between them. | | `data.transcript.segments` | Every line, with its speaker, `startMs` (milliseconds since the room opened), text and `guest` (`true` for someone who joined with a meeting link), even when `text` was cut. | | `data.decision.action` | Always `run_devops_agent`: the API name of **Send to agents**. | | `data.decision.routedBy` | `dcision` when Dcision chose it, with its `confidence` (0 to 1) and `executionId`. `person` when someone did, with `requestedBy` (`id`, `name`). | | `data.link` | The channel in the Fishcamp app. | | `data.reply` | How to answer in the channel: `POST` a message with the agent's own key. See [Reply in the channel](#reply). | What happens next: * **Every agent answers `2xx` within 10 seconds:** the timeline says who got it, for example "Maya Lopes sent the transcript to Scout." * **Some fail:** the timeline also says who didn't answer, for example "Ledger didn't answer." * **All fail, or no agent in the channel takes handoffs:** the transcript shows **Failed** with the reason, and anyone in the channel can choose **Try again**. **Send to agents again** sends the transcript to every agent once more, including those that already got it. Skip transcripts you've already handled, by `data.transcript.id`. ## Test event **Send test event**, on the agent's page, sends a `ping`. It's signed like any other event, but it has no workspace or channel, and its ID starts with `test_`: ```json { "id": "test_1793894400000", "event": "ping", "sentAt": "2026-11-05T16:00:00.000Z", "agent": { "id": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "name": "Scout", "autonomy": "PROPOSE" }, "data": { "message": "Test event from Fishcamp" } } ``` ## Reply in the channel Post with the agent's own key. It only reaches the agent's channels, and with an API key the `X-Workspace-Id` header is optional: ```bash curl -X POST "https://api.fishcamp.space/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/messages" \ -H "Authorization: Bearer cwk_YOUR_AGENT_KEY" \ -H "Content-Type: application/json" \ -d '{"content": "Two things to track: the copy by Thursday, and the preview link for Ana."}' ``` The message appears under the agent's name with an **AI** label. Posting needs the `write` scope, so an agent that only **Observes** can't reply. MCP clients can use the [`post_message`](https://docs.fishcamp.space/docs/agents/mcp#post_message) tool instead. --- # API keys > Give scripts, bots and AI agents their own credential. Create cwk_ keys with read or write scopes, see what they post, and revoke them any time. Source: https://docs.fishcamp.space/docs/agents/api-keys New in November 2026 An API key lets a script, a bot or an AI agent (such as Claude Code) use Fishcamp without signing in as a person. Each key belongs to one workspace, has its own name, and shows up under that name when it posts. ## Create a key Admins create keys: 1. Go to **Settings** → **API keys**. 2. Name the key after who or what uses it, for example `Claude Code (Maya)` or `Release bot`. 3. Choose its scopes: `read`, and `write` if it should post. 4. Create the key and copy it. It starts with `cwk_` and **is shown only once**: Fishcamp keeps only a hash of it, so it can't show it again. Store the key in a secret manager or an environment variable. If you lose it, create a new one and revoke the old one. ## Scopes | Scope | What the key can do | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read` | List channels and members, read and search messages, see who's in an audio room, list and read transcripts, see the integrations. Every `GET` request needs it, and so does the MCP server. | | `write` | Post messages, edit and delete the messages the key posted, and create channels. | Give each key only what it needs: a summarizing agent needs `read`; a release bot that posts needs `write` too. A key with `write` but not `read` can post, but every `GET` and the MCP server answer `403 forbidden`. ## Workspace keys and agent keys New in November 2026 There are two kinds of keys, and both start with `cwk_`: | | Workspace key | Agent key | | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Where it's created | **Settings** → **API keys** | When you add an [AI agent](https://docs.fishcamp.space/docs/agents); rotate it on the agent's page | | Channels it reaches | The ones the admin who created it can see: public channels, plus the private channels and direct messages that admin is in | Only the agent's channels, public or private | | Scopes | The ones you choose | From the agent's autonomy: **Observes** gets `read`; the other levels get `read` and `write` | * **A key acts as the admin who created it**: for an agent key, the admin who added the agent or last rotated its key. It sees what that admin sees, but never with an admin's powers, and if that admin leaves the workspace, it stops working (`401`). * **An agent key never sees beyond the agent's channels**, in the REST API and the MCP server alike. Other channels, direct messages included, answer `404`, as if they didn't exist. It also needs the admin behind it to see the channel: a private channel that admin isn't in stays out of reach. * **A channel created with an agent key joins the agent's channels**, so the agent keeps working in it. * **Workspace-wide lists aren't limited by channel.** An agent key still sees everyone in the workspace (`GET /workspace/members` and the MCP tool `list_members`) and the integrations. ## What a key can and can't do * **One workspace.** A key acts only inside the workspace where it was created. * **Visible on the timeline.** Messages posted with a key appear under the key's name with an **AI** label, so everyone can tell them apart from people. * **Never an admin.** Even a key an admin created can't archive or rename channels, delete other people's messages, invite people, change roles, or manage incoming webhooks, API keys, AI agents, integrations, invite links and meeting links: requests made with an API key get `403 forbidden`. * **Only its own messages.** A key edits and deletes only the messages it posted itself, never the messages its creator wrote as a person. * **Some things are for people only.** Joining an audio room, choosing where a transcript goes, reading a channel's meeting link or the AI agents, and anything outside the key's workspace (`GET /workspaces`, invites received) answer `403` to a key. * **Revocable any time.** In **Settings** → **API keys**, choose **Revoke** next to the key. The next request with it fails with `401 unauthorized`. ## Use a key Send it as a bearer token. With the [MCP server](https://docs.fishcamp.space/docs/agents/mcp), for example from Claude Code: ```bash claude mcp add --transport http fishcamp https://api.fishcamp.space/api/mcp --header "Authorization: Bearer cwk_YOUR_KEY" ``` With the [REST API](https://docs.fishcamp.space/docs/api), together with the workspace ID: ```bash curl "https://api.fishcamp.space/api/v1/channels" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" ``` ## Good practices * **One key per tool or person.** Revoking one doesn't break the others, and the name on each message tells you which one posted. * **Rotate** by creating a new key, switching the tool to it, then revoking the old key. * **Never paste a key in a channel.** If that happens, revoke it right away. --- # MCP server > Connect Claude Code and other MCP clients to Fishcamp. Endpoint, setup, and every tool with its arguments and an example. Source: https://docs.fishcamp.space/docs/agents/mcp New in November 2026 Fishcamp has a built-in [Model Context Protocol](https://modelcontextprotocol.io) server. Connect an AI agent such as Claude Code and it can read channels, search messages, check audio rooms and transcripts, and post, all inside your workspace and under the name of an [API key](https://docs.fishcamp.space/docs/agents/api-keys). * **Endpoint:** `https://api.fishcamp.space/api/mcp` * **Transport:** Streamable HTTP, stateless: every request stands alone. * **Authentication:** `Authorization: Bearer cwk_…` on every request. ## Set it up ### 1. Create an API key An admin creates the key in **Settings** → **API keys**: `read` lets the agent look around, `write` also lets it post messages and create channels. The MCP server needs `read`: a key with only `write` gets `403`. An [AI agent's key](https://docs.fishcamp.space/docs/agents) works too. See [API keys](https://docs.fishcamp.space/docs/agents/api-keys). ### 2. Connect Claude Code ```bash claude mcp add --transport http fishcamp https://api.fishcamp.space/api/mcp --header "Authorization: Bearer cwk_YOUR_KEY" ``` * Add `--scope user` to use Fishcamp in all your projects, not only the current one. * In Claude Code, run `/mcp` to check that `fishcamp` is connected. * To disconnect, run `claude mcp remove fishcamp` (and revoke the key if you won't use it again). ### Other MCP clients Clients that support remote servers take an entry like this one. The file and the top-level key depend on the client: `.mcp.json` (Claude Code) and `mcp.json` (Cursor) use `mcpServers`; `.vscode/mcp.json` (VS Code) uses `servers`. ```json { "mcpServers": { "fishcamp": { "type": "http", "url": "https://api.fishcamp.space/api/mcp", "headers": { "Authorization": "Bearer cwk_YOUR_KEY" } } } } ``` For clients that only start local (stdio) servers, such as Claude Desktop, bridge with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): ```json { "mcpServers": { "fishcamp": { "command": "npx", "args": ["-y", "mcp-remote", "https://api.fishcamp.space/api/mcp", "--header", "Authorization:${FISHCAMP_AUTH}"], "env": { "FISHCAMP_AUTH": "Bearer cwk_YOUR_KEY" } } } } ``` Chat apps whose connectors only sign in with OAuth, such as ChatGPT, can't use API keys. ## Things to ask ```txt Who's in the audio room in #platform right now? Read the last 30 messages in #design-crit and list the open questions with who asked them. Search for "rate limit" in #incidents and summarize what we decided. Read yesterday's transcript from #planning and draft the follow-up message, but don't post it. Create a channel called release-2-5 and post the checklist from RELEASE.md there. ``` Messages the agent posts appear under the key's name with an **AI** label. ## Tools | Tool | What it does | Scope | | --------------------------------------- | ----------------------------------------------------------------------- | ------- | | [`whoami`](#whoami) | Shows the key's name and scopes, the workspace and who the key acts for | `read` | | [`list_channels`](#list_channels) | Lists the channels the key can see | `read` | | [`read_messages`](#read_messages) | Reads a channel's messages | `read` | | [`search_messages`](#search_messages) | Searches messages | `read` | | [`post_message`](#post_message) | Posts a message | `write` | | [`create_channel`](#create_channel) | Creates a channel | `write` | | [`list_members`](#list_members) | Lists the people in the workspace | `read` | | [`get_audio_room`](#get_audio_room) | Shows who's in a channel's audio room | `read` | | [`list_transcripts`](#list_transcripts) | Lists transcripts | `read` | | [`read_transcript`](#read_transcript) | Reads one transcript | `read` | Where a tool takes a `channel`, pass the channel's ID or its name, for example `design-crit`. With an [AI agent's key](https://docs.fishcamp.space/docs/agents/api-keys#agent-keys), the tools only see the agent's channels: `list_channels`, `search_messages` and `list_transcripts` leave the others out, and any other channel answers "not found". `list_members` still lists everyone in the workspace. ### `whoami` Returns the workspace the key belongs to, the person it acts for (the admin who created it) and the key's name and scopes. Call it first to check the connection. No arguments. ```json {} ``` ### `list_channels` Lists the channels the key can see, with their IDs, names and descriptions. No arguments. ```json {} ``` ### `read_messages` Reads the latest messages of a channel. To go further back, pass the cursor from the previous result as `before`. | Argument | Type | Required | Description | | --------- | ------- | -------- | ---------------------------------------------------- | | `channel` | string | Yes | Channel ID or name. | | `limit` | integer | No | How many messages, from 1 to 100. | | `before` | string | No | Cursor from a previous call, to read older messages. | ```json { "channel": "design-crit", "limit": 20 } ``` ### `search_messages` Searches message text in the channels the key can see. | Argument | Type | Required | Description | | --------- | ------- | -------- | -------------------------------------- | | `query` | string | Yes | Words to look for. | | `channel` | string | No | Only search this channel (ID or name). | | `limit` | integer | No | Maximum number of results. | ```json { "query": "rate limit", "channel": "incidents", "limit": 10 } ``` ### `post_message` Posts a message to a channel, under the key's name with an **AI** label. Needs `write`. | Argument | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------ | | `channel` | string | Yes | Channel ID or name. | | `text` | string | Yes | The message, up to 4,000 characters. | ```json { "channel": "general", "text": "Release 2.5 is out. Notes: https://example.com/releases/2.5" } ``` ### `create_channel` Creates a channel. Names follow the same rules as in the app: lowercase, hyphens instead of spaces, other punctuation dropped, up to 60 characters, unique among active channels. Needs `write`. | Argument | Type | Required | Description | | ------------- | ------- | -------- | -------------------------------------------------- | | `name` | string | Yes | Channel name. | | `description` | string | No | What the channel is for, up to 250 characters. | | `private` | boolean | No | `true` creates a private channel. Default: public. | ```json { "name": "release-2-5", "description": "Checklist and status for release 2.5", "private": false } ``` ### `list_members` Lists the people in the workspace with their names, emails and roles. No arguments. ```json {} ``` ### `get_audio_room` Shows whether the channel's audio room is open, since when, who's in it and who's sharing a screen. | Argument | Type | Required | Description | | --------- | ------ | -------- | ------------------- | | `channel` | string | Yes | Channel ID or name. | ```json { "channel": "platform" } ``` ### `list_transcripts` Lists recent [transcripts](https://docs.fishcamp.space/docs/guides/transcription), with when the call happened and what was decided for it. | Argument | Type | Required | Description | | --------- | ------- | -------- | ------------------------------------------------ | | `channel` | string | No | Only transcripts from this channel (ID or name). | | `limit` | integer | No | Maximum number of transcripts. | ```json { "channel": "planning", "limit": 5 } ``` ### `read_transcript` Reads one transcript: every line with its speaker, and the routing decision (discard, save to OpenSync or send to agents). | Argument | Type | Required | Description | | -------- | ------ | -------- | --------------------------------------- | | `id` | string | Yes | Transcript ID, from `list_transcripts`. | ```json { "id": "3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b" } ``` ## Call it without a client The server speaks JSON-RPC over HTTP, so you can test it with `curl`. List the tools: ```bash curl -sS https://api.fishcamp.space/api/mcp \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` Call one: ```bash curl -sS https://api.fishcamp.space/api/mcp \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_messages","arguments":{"channel":"general","limit":5}}}' ``` The answer is a JSON-RPC message; depending on the server's choice it arrives as plain JSON or as a server-sent event (a `data: {…}` line). ## Troubleshooting | Symptom | Fix | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `401` or "unauthorized" | The key is missing, mistyped or revoked. Check the header is exactly `Authorization: Bearer cwk_…`. | | A write tool fails | The key only has `read`. Create a key with `write`. | | `403` on every request | The key has no `read` scope. Create a key with `read`. | | "not found" for a channel | Check the name or ID with `list_channels`. Private channels the key can't see look like they don't exist, and an agent's key only sees the agent's channels. | | Claude Code doesn't list `fishcamp` | Run `claude mcp list`. If you added it with the default scope, it only exists in the project where you ran the command. | --- # REST API > Base URL, authentication, the workspace header, errors, rate limits and pagination of the Fishcamp REST API, with the list of endpoints. Source: https://docs.fishcamp.space/docs/api The REST API is the same one the Fishcamp app uses. Everything is JSON over HTTPS. ```txt https://api.fishcamp.space/api/v1 ``` ## Authentication Every request carries a bearer token: ```http Authorization: Bearer cwk_YOUR_KEY ``` * **Scripts, bots and AI agents** use an [API key](https://docs.fishcamp.space/docs/agents/api-keys) (`cwk_…`). Every `GET` needs the `read` scope; creating or changing things needs `write`. A key sees what the admin who created it sees, and an [AI agent's key](https://docs.fishcamp.space/docs/agents/api-keys#agent-keys) only reaches the agent's channels. * **The Fishcamp app** calls the same endpoints with the signed-in person's short-lived session token. * **Admin actions need a person.** Endpoints marked *Admins* refuse API keys with `403 forbidden`, and so do those marked *People*. A key never acts as an admin, even when an admin created it. * **Some routes need no key or session.** [Incoming webhooks](https://docs.fishcamp.space/docs/guides/incoming-webhooks) carry their secret in the URL. Guests use the [`/meet/:code` routes](https://docs.fishcamp.space/docs/api/meeting-links#public-routes) without an account (sending audio takes the guest token they get when they join), and the invite link preview, `GET /invite-links/:code`, is public. A missing, invalid or expired token gets `401 unauthorized`. ## The workspace header Endpoints that work inside a workspace (channels, messages, members, audio rooms, invite and meeting links, webhooks, API keys, AI agents, transcripts, integrations) need the workspace's ID: ```http X-Workspace-Id: 9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f ``` `GET /workspaces` lists the workspaces of the signed-in person with their IDs; for an API key, the MCP tool [`whoami`](https://docs.fishcamp.space/docs/agents/mcp#whoami) returns its workspace. Without the header, or for a workspace you're not a member of, the answer is `403 forbidden`. A key only works in its own workspace: with a key the header is optional, and another workspace's ID gets `403`. ## A first request ```bash curl "https://api.fishcamp.space/api/v1/channels" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" ``` ```json [ { "id": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "workspaceId": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "kind": "STANDARD", "visibility": "PUBLIC", "name": "general", "topic": "Team conversations", "voiceEnabled": true, "cameraEnabled": true, "screenShareEnabled": true, "transcriptionEnabled": false, "createdAt": "2026-11-02T13:05:11.000Z", "lastMessageAt": "2026-11-05T14:32:08.000Z", "directPeer": null, "isMember": true, "unread": false } ] ``` ## Conventions * **IDs** are UUIDs. **Dates** are ISO 8601 in UTC. * **Creating** returns `201 Created` with the new object. **Deleting** and actions without a result return `204 No Content`. * **Visibility**: a private channel you're not in, or a channel, message, member or invite ID from another workspace, answers `404 not_found`, never `403`, so the API doesn't reveal that it exists. (A whole workspace you're not in, named in `X-Workspace-Id`, answers `403`.) * **Request IDs**: every error carries a `requestId`. Send your own in an `X-Request-Id` header (6 to 64 letters, digits, `_` or `-`) to match logs on both sides. * **Body size**: up to 512 KB per request. Audio sent for transcription goes in chunks of up to 1 MB. * **Email language**: `POST /auth/email-code` (the sign-in code) and `POST /workspace/invites` take an optional `locale`, `en` (the default), `pt` or `es`, and send the email in that language. Any other value gets `422`. New in November 2026 ## Errors Every error has the same shape: ```json { "error": { "code": "validation_error", "message": "Some fields are invalid", "details": { "errors": [{ "path": "content", "message": "Messages must be 1 to 4000 characters" }] }, "requestId": "req_5f0c2a9b1e7d4c3a" } } ``` `message` is written for people and can be shown as is. `details` only appears when there's something useful in it, like the list of invalid fields (up to 20). | Status | `code` | When | | ------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `validation_error` | The body isn't valid JSON. | | 401 | `unauthorized` | No token, or an invalid, expired or revoked one. | | 403 | `forbidden` | Not a member of the workspace, missing `X-Workspace-Id`, a role that's too low, an API key on an admin or people-only action, or a key without `read` on a `GET`. | | 404 | `not_found` | It doesn't exist, or you can't see it. | | 409 | `conflict` | It clashes with something, for example a channel name that's taken or removing the last owner. | | 409 | `voice_disabled` | Joining an audio room, or sharing a meeting link, in a channel with voice turned off. | | 409 | `waiting_for_host`, `meeting_full`, `meeting_unavailable` | A guest can't join yet: nobody from the workspace is in the room, it already has 20 guests, or the channel's voice is off. See [Meeting link endpoints](https://docs.fishcamp.space/docs/api/meeting-links#join). | | 409 | `transcript_busy`, `transcript_empty` | Choosing where a transcript goes while it's still being made or decided, or when nothing was transcribed. | | 409 | `transcript_closed`, `transcription_off`, `no_audio_room` | Sending audio for a transcript that's already closed, in a channel with transcription off, or with no audio room open. | | 409 | `no_webhook` | Testing an AI agent, or rotating its webhook secret, when it has no webhook URL. | | 410 | `invite_expired`, `invite_unavailable` | The email invite expired, was revoked or was already used. | | 410 | `invite_link_expired`, `invite_link_revoked`, `invite_link_used_up` | The invite link expired, was turned off or reached its limit of uses. | | 410 | `meeting_link_revoked` | The meeting link was turned off or replaced, including for a guest who was already in the room. | | 413 | `payload_too_large` | The body is larger than 512 KB. | | 415 | `unsupported_media_type` | The body isn't JSON. Send `Content-Type: application/json`. | | 422 | `validation_error` | A field is missing, has the wrong type or breaks a limit. | | 422 | `integration_rejected` | OpenSync refused the token or the space. | | 429 | `rate_limited` | Too many requests. Wait a little and retry. | | 500 | `internal_error` | Something failed on our side. Retry later, and include the `requestId` if you contact us. | | 502 | `integration_unreachable` | OpenSync didn't answer, or its address leads to a private network. | | 503 | `media_unavailable` | Audio rooms aren't available on this server. | | 503 | `email_unavailable` | The sign-in email couldn't be sent. Try again in a moment. | ## Rate limits Requests are counted per client address, per endpoint and per minute. Over the limit, the answer is `429 rate_limited`. | Endpoint | Limit per minute | | ----------------------------------------------- | ------------------------------------------------- | | Most endpoints | 300 | | `POST /webhooks/inbound/:token` | 60 | | `POST /channels/:id/call/token` | 20 | | `POST /workspace/invites` | 20 | | `POST /workspaces` | 10 | | `GET /invites/token/:token` (invite preview) | 30 | | `POST /invites/token/:token/accept` | 20 | | `POST /workspace/invite-links` | 20 | | `GET /invite-links/:code` (invite link preview) | 30 | | `POST /invite-links/:code/join` | 20 | | `GET /meet/:code` (meeting preview) | 60 | | `POST /meet/:code/join` | 10 | | `POST /meet/:code/transcript` | 12 | | `POST /auth/email-code` (sign-in codes) | 5, and at most 3 codes per email every 10 minutes | ## Pagination Lists that can grow without end, like messages, are paginated with a cursor. Each page has a `nextCursor`; pass it as `cursor` to get the next (older) page, until `nextCursor` is `null`. See [Message endpoints](https://docs.fishcamp.space/docs/api/messages#list-messages). ## Endpoints | Method | Path | What it does | | -------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `GET` | [`/workspaces`](https://docs.fishcamp.space/docs/api/workspaces#list-workspaces) | List your workspaces (people) | | `POST` | [`/workspaces`](https://docs.fishcamp.space/docs/api/workspaces#create-a-workspace) | Create a workspace (people) | | `GET` | [`/workspace/members`](https://docs.fishcamp.space/docs/api/workspaces#list-members) | List the people in the workspace | | `POST` | [`/workspace/invites`](https://docs.fishcamp.space/docs/api/workspaces#invite-someone) | Invite someone by email (admins) | | `GET` | [`/workspace/invite-links`](https://docs.fishcamp.space/docs/api/invite-links#list-invite-links) | List invite links (admins) · New in November 2026 | | `POST` | [`/workspace/invite-links`](https://docs.fishcamp.space/docs/api/invite-links#create-an-invite-link) | Create an invite link (admins) · New in November 2026 | | `DELETE` | [`/workspace/invite-links/:id`](https://docs.fishcamp.space/docs/api/invite-links#turn-off-an-invite-link) | Turn off an invite link (admins) · New in November 2026 | | `GET` | [`/invite-links/:code`](https://docs.fishcamp.space/docs/api/invite-links#preview-an-invite-link) | Preview an invite link, no token · New in November 2026 | | `POST` | [`/invite-links/:code/join`](https://docs.fishcamp.space/docs/api/invite-links#join-with-an-invite-link) | Join with an invite link (people) · New in November 2026 | | `GET` | [`/channels`](https://docs.fishcamp.space/docs/api/channels#list-channels) | List channels and direct messages you can see | | `POST` | [`/channels`](https://docs.fishcamp.space/docs/api/channels#create-a-channel) | Create a channel | | `PATCH` | [`/channels/:id`](https://docs.fishcamp.space/docs/api/channels#update-a-channel) | Rename, describe, or switch voice, camera, screen and transcription | | `DELETE` | [`/channels/:id`](https://docs.fishcamp.space/docs/api/channels#archive-a-channel) | Archive a channel (admins, people only) | | `POST` | [`/channels/direct`](https://docs.fishcamp.space/docs/api/channels#open-a-direct-message) | Open a direct message | | `GET` | [`/channels/:id/messages`](https://docs.fishcamp.space/docs/api/messages#list-messages) | List messages, newest page first | | `POST` | [`/channels/:id/messages`](https://docs.fishcamp.space/docs/api/messages#send-a-message) | Send a message | | `PATCH` | [`/messages/:id`](https://docs.fishcamp.space/docs/api/messages#edit-a-message) | Edit your message | | `DELETE` | [`/messages/:id`](https://docs.fishcamp.space/docs/api/messages#delete-a-message) | Delete a message | | `POST` | [`/channels/:id/call/token`](https://docs.fishcamp.space/docs/api/audio-rooms#get-a-join-token) | Get a token to join the audio room (people) | | `GET` | [`/channels/:id/call`](https://docs.fishcamp.space/docs/api/audio-rooms#get-the-room) | See who's in the audio room | | `GET` | [`/channels/:id/meeting-link`](https://docs.fishcamp.space/docs/api/meeting-links#get-the-link) | Get the channel's meeting link (people) · New in November 2026 | | `POST` | [`/channels/:id/meeting-link`](https://docs.fishcamp.space/docs/api/meeting-links#create-the-link) | Create or copy the meeting link (people) · New in November 2026 | | `POST` | [`/channels/:id/meeting-link/reset`](https://docs.fishcamp.space/docs/api/meeting-links#replace-the-link) | Replace the meeting link (people) · New in November 2026 | | `DELETE` | [`/channels/:id/meeting-link`](https://docs.fishcamp.space/docs/api/meeting-links#turn-off-the-link) | Turn off the meeting link (people) · New in November 2026 | | `POST` | [`/channels/:id/call/guests/:identity/remove`](https://docs.fishcamp.space/docs/api/meeting-links#remove-a-guest) | Remove a guest from the audio room (people) · New in November 2026 | | `GET` | [`/meet/:code`](https://docs.fishcamp.space/docs/api/meeting-links#preview) | Preview a meeting, no token · New in November 2026 | | `POST` | [`/meet/:code/join`](https://docs.fishcamp.space/docs/api/meeting-links#join) | Join a meeting as a guest, no token · New in November 2026 | | `POST` | [`/meet/:code/transcript`](https://docs.fishcamp.space/docs/api/meeting-links#guest-transcript) | Send a guest's audio for transcription (guest token) · New in November 2026 | | `GET` | [`/channels/:id/webhooks`](https://docs.fishcamp.space/docs/api/webhooks#list-webhooks) | List incoming webhooks (admins) | | `POST` | [`/channels/:id/webhooks`](https://docs.fishcamp.space/docs/api/webhooks#create-a-webhook) | Create an incoming webhook (admins) | | `DELETE` | [`/channels/:id/webhooks/:webhookId`](https://docs.fishcamp.space/docs/api/webhooks#revoke-a-webhook) | Revoke an incoming webhook (admins) | | `POST` | [`/webhooks/inbound/:token`](https://docs.fishcamp.space/docs/api/webhooks#post-to-a-webhook) | Post through an incoming webhook | | `GET` | [`/workspace/api-keys`](https://docs.fishcamp.space/docs/api/api-keys#list-api-keys) | List API keys (admins) · New in November 2026 | | `POST` | [`/workspace/api-keys`](https://docs.fishcamp.space/docs/api/api-keys#create-an-api-key) | Create an API key (admins) · New in November 2026 | | `DELETE` | [`/workspace/api-keys/:id`](https://docs.fishcamp.space/docs/api/api-keys#revoke-an-api-key) | Revoke an API key (admins) · New in November 2026 | | `GET` | [`/agents`](https://docs.fishcamp.space/docs/api/agents#list-agents) | List AI agents (people) · New in November 2026 | | `POST` | [`/agents`](https://docs.fishcamp.space/docs/api/agents#create-an-agent) | Create an AI agent (admins) · New in November 2026 | | `PATCH` | [`/agents/:id`](https://docs.fishcamp.space/docs/api/agents#update-an-agent) | Change, pause or resume an agent (admins) · New in November 2026 | | `DELETE` | [`/agents/:id`](https://docs.fishcamp.space/docs/api/agents#delete-an-agent) | Delete an agent (admins) · New in November 2026 | | `POST` | [`/agents/:id/rotate-key`](https://docs.fishcamp.space/docs/api/agents#rotate-the-api-key) | Rotate an agent's API key (admins) · New in November 2026 | | `POST` | [`/agents/:id/rotate-secret`](https://docs.fishcamp.space/docs/api/agents#rotate-the-webhook-secret) | Rotate an agent's webhook secret (admins) · New in November 2026 | | `POST` | [`/agents/:id/test`](https://docs.fishcamp.space/docs/api/agents#send-a-test-event) | Send a test event to an agent (admins) · New in November 2026 | | `GET` | [`/channels/:id/transcripts`](https://docs.fishcamp.space/docs/api/transcripts#list-transcripts) | List a channel's transcripts · New in November 2026 | | `GET` | [`/transcripts/:id`](https://docs.fishcamp.space/docs/api/transcripts#read-a-transcript) | Read a transcript · New in November 2026 | | `POST` | [`/transcripts/:id/route`](https://docs.fishcamp.space/docs/api/transcripts#route-a-transcript) | Choose where a transcript goes (people) · New in November 2026 | | `GET` | [`/integrations`](https://docs.fishcamp.space/docs/api/integrations#list-integrations) | List the workspace's integrations · New in November 2026 | | `PUT` | [`/integrations/opensync`](https://docs.fishcamp.space/docs/api/integrations#connect-opensync) | Connect OpenSync (admins) · New in November 2026 | | `POST` | [`/integrations/:provider/test`](https://docs.fishcamp.space/docs/api/integrations#test) | Test an integration (admins) · New in November 2026 | | `DELETE` | [`/integrations/:provider`](https://docs.fishcamp.space/docs/api/integrations#disconnect) | Disconnect an integration (admins) · New in November 2026 | `GET /health` needs no token and answers `{ "status": "ok", "version": "…" }` while the API is up. --- # Workspace endpoints > List and create workspaces, list the people in a workspace, invite someone by email, and manage roles and invites through the REST API. Source: https://docs.fishcamp.space/docs/api/workspaces Paths are relative to `https://api.fishcamp.space/api/v1`. See the [REST API overview](https://docs.fishcamp.space/docs/api) for authentication, the `X-Workspace-Id` header and errors. ## List workspaces ```http GET /workspaces ``` **People.** The workspaces of the signed-in person, oldest first, with their role in each. No `X-Workspace-Id` needed. An API key gets `403`: it belongs to one workspace and can't see the others of the person who created it. ```json [ { "id": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "name": "Northstar Studio", "slug": "northstar-studio", "role": "OWNER", "lastChannelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10" } ] ``` `role` is `OWNER`, `ADMIN` or `MEMBER`. `lastChannelId` is the channel the person had open last, or `null`. ## Create a workspace ```http POST /workspaces ``` | Field | Type | Required | Description | | ------ | ------ | -------- | ----------------------------------- | | `name` | string | Yes | Workspace name, 2 to 60 characters. | ```bash curl -X POST "https://api.fishcamp.space/api/v1/workspaces" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "Northstar Studio"}' ``` **People.** Returns `201 Created` with the workspace, in the same shape as the list. The person who creates it becomes its owner, and Fishcamp creates a public `#general` channel in it. The address (`slug`) comes from the name. Limit: 10 per minute. ## List members ```http GET /workspace/members ``` Everyone in the workspace, oldest member first. ```json [ { "membershipId": "5d0e7a2c-9b1f-4c3e-8a6d-2f4b7c9e1a0d", "role": "ADMIN", "joinedAt": "2026-11-02T13:05:11.000Z", "profile": { "id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes", "email": "maya@northstar.studio", "avatarUrl": null } } ] ``` Use `profile.id` wherever an endpoint asks for a `profileId` (direct messages, adding someone to a private channel) and `membershipId` to change a role or remove someone. ## Invite someone ```http POST /workspace/invites ``` **Admins.** Sends an invite email and returns the invite. | Field | Type | Required | Description | | -------- | ------------------- | -------- | --------------------------------------------------------------------------------------------- | | `email` | string | Yes | Email address of the person to invite. | | `role` | `MEMBER` or `ADMIN` | No | Role they get when they accept. Default: `MEMBER`. | | `locale` | `en`, `pt` or `es` | No | Language of the invite email. Default: `en`. Any other value gets `422`. New in November 2026 | ```bash curl -X POST "https://api.fishcamp.space/api/v1/workspace/invites" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"email": "theo@northstar.studio", "role": "MEMBER", "locale": "pt"}' ``` ```json { "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f", "email": "theo@northstar.studio", "role": "MEMBER", "status": "PENDING", "createdAt": "2026-11-05T14:00:00.000Z", "expiresAt": "2026-11-12T14:00:00.000Z", "acceptUrl": "https://fishcamp.space/invite/inv_…", "emailSent": true } ``` * The invite only works for that email address and **expires after 7 days**. * `acceptUrl` is returned only here, so you can send it yourself, for example when `emailSent` is `false`. * Inviting the same address again renews the invite with a new link; the old link stops working. * Someone who is already in the workspace gets `409 conflict`. Limit: 20 per minute. To share one link with many people, to the workspace or straight to a channel, use an [invite link](https://docs.fishcamp.space/docs/api/invite-links) instead. New in November 2026 ## More endpoints | Method and path | Who | What it does | | ----------------------------------------- | ------------------- | --------------------------------------------------------------------------------------- | | `GET /workspace/invites` | Admins | Pending invites. | | `DELETE /workspace/invites/:inviteId` | Admins | Revokes a pending invite. `204`. | | `PATCH /workspace/members/:membershipId` | Admins | Changes a role: body `{ "role": "MEMBER" }`. Only owners can give or take away `OWNER`. | | `DELETE /workspace/members/:membershipId` | Admins, or yourself | Removes someone, or lets you leave. `204`. Only owners can remove an owner. | | `PATCH /workspace` | Admins | Renames the workspace: body `{ "name": "…" }`. | | `GET /me` | Anyone signed in | Your profile: `id`, `email`, `name`, `avatarUrl`. | The last owner can't be demoted, removed or leave: those requests get `409 conflict` until someone else is an owner. See [People and roles](https://docs.fishcamp.space/docs/guides/people-and-roles). --- # Invite link endpoints > Create, list and turn off shareable invite links to a workspace or a channel, preview one without signing in, and join with it. Source: https://docs.fishcamp.space/docs/api/invite-links New in November 2026 Paths are relative to `https://api.fishcamp.space/api/v1`. Listing, creating and turning off links are admin actions that need the `X-Workspace-Id` header and a person (requests with an API key get `403 forbidden`). Previewing a link needs no token; joining needs a signed-in person. How invite links work in the app is in [People and roles](https://docs.fishcamp.space/docs/guides/people-and-roles#invite-links). ## The invite link object ```json { "id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a", "code": "Rk7mPq2xWz9T", "url": "https://fishcamp.space/join/Rk7mPq2xWz9T", "channelId": null, "channelName": null, "createdBy": { "id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes" }, "expiresAt": "2026-12-05T14:00:00.000Z", "maxUses": null, "uses": 4, "createdAt": "2026-11-05T14:00:00.000Z" } ``` | Field | Description | | -------------------------- | --------------------------------------------------------------------------------- | | `code` | 12 letters and digits: the part of the link after `/join/`. | | `url` | The link to share. | | `channelId`, `channelName` | The channel the link leads to, or `null` for a link to the workspace. | | `createdBy` | The admin who created it, or `null`. | | `expiresAt` | When it stops working, or `null` if it never expires. | | `maxUses`, `uses` | How many people can join with it (`null` for no limit), and how many already did. | ## List invite links ```http GET /workspace/invite-links ``` **Admins.** The workspace's active links, newest first, up to 100. Links that were turned off, expired, used up, or that lead to an archived channel aren't listed, and links to a private channel only show up for admins who are in it. ## Create an invite link ```http POST /workspace/invite-links ``` **Admins.** Limit: 20 per minute. | Field | Type | Required | Description | | --------------- | ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channelId` | string | No | A channel to lead to: people who join are also added to it, even if it's private. Default: `null`, the workspace only. | | `expiresInDays` | `1`, `7`, `30` or `null` | No | Days until it expires; `null` never expires. Default: `30`. | | `maxUses` | integer or `null` | No | From 1 to 1,000 people; `null` has no limit. Default: `null`. | | `reuse` | boolean | No | Default `true`: if you already created an active link to the same place with the same settings, and it's still as good as a new one (almost all of its time left and, with a use limit, no uses yet), you get that link back. `false` always creates a new one. | ```bash curl -X POST "https://api.fishcamp.space/api/v1/workspace/invite-links" \ -H "Authorization: Bearer YOUR_SESSION_TOKEN" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "expiresInDays": 7, "maxUses": 25}' ``` Returns `201 Created` with the link. For a channel link, you must be able to see the channel: a private channel only if you're in it. A channel you can't see, or that was archived, answers `404`. Direct messages, and values of `expiresInDays` or `maxUses` outside the list above, get `422`. ## Turn off an invite link ```http DELETE /workspace/invite-links/:linkId ``` **Admins.** The link stops working at once. Returns `204`, or `404` if it doesn't exist or is already off. People who joined with it stay. ## Preview an invite link ```http GET /invite-links/:code ``` No token. Limit: 30 per minute. This is what the join page shows before anyone signs in. ```json { "workspaceName": "Northstar Studio", "channelName": "design-crit", "invitedBy": "Maya Lopes", "memberCount": 12, "status": "active" } ``` `status` is `active`, `expired`, `revoked` (turned off, or its channel was archived) or `used_up`. An unknown code answers `404`. ## Join with an invite link ```http POST /invite-links/:code/join ``` **People**, signed in. Limit: 20 per minute. No body. Adds the person to the workspace as a **Member** and, for a channel link, to the channel. The channel gets a "joined with an invite link" pill. ```json { "workspaceId": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "slug": "northstar-studio", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "joinedWorkspace": true } ``` `joinedWorkspace` is `false` when the person was already a member. That doesn't use the link up, except for a channel link with a use limit: a member who joins the channel through it uses one. Someone already in the workspace (and, for a channel link, in the channel) gets `200` even after the link stopped working, and nothing changes. | Error | When | | ------------------------- | ------------------------------------------------------------------------------------- | | `410 invite_link_expired` | The link expired. | | `410 invite_link_revoked` | The link was turned off, or its channel was archived. | | `410 invite_link_used_up` | The link reached its limit of uses, also for a member joining the channel through it. | | `404 not_found` | The code doesn't exist. | | `401 unauthorized` | Nobody is signed in. | To invite one person by email instead, see [Invite someone](https://docs.fishcamp.space/docs/api/workspaces#invite-someone). --- # Channel endpoints > List, create, update and archive channels, switch voice, camera, screen and transcription, open direct messages and add people through the REST API. Source: https://docs.fishcamp.space/docs/api/channels Paths are relative to `https://api.fishcamp.space/api/v1` and all need the `X-Workspace-Id` header. See the [REST API overview](https://docs.fishcamp.space/docs/api) for authentication and errors. ## The channel object ```json { "id": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "workspaceId": "9b2f6c1d-3e4a-4f5b-8c7d-1a2b3c4d5e6f", "kind": "STANDARD", "visibility": "PRIVATE", "name": "design-crit", "topic": "Weekly design critique", "voiceEnabled": true, "cameraEnabled": true, "screenShareEnabled": true, "transcriptionEnabled": false, "createdAt": "2026-11-03T10:15:00.000Z", "lastMessageAt": "2026-11-05T14:32:08.000Z", "directPeer": null, "isMember": true, "unread": true } ``` | Field | Description | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `STANDARD` for channels, `DIRECT` for direct messages. | | `visibility` | `PUBLIC` or `PRIVATE`. Direct messages are always `PRIVATE`. | | `name` | Channel name, without `#`. `null` for direct messages. | | `topic` | The channel's description (called **Description** in the app), or `null`. | | `voiceEnabled`, `cameraEnabled`, `screenShareEnabled` | The channel's audio room switches. | | `transcriptionEnabled` | The channel's transcription switch. New in November 2026 | | `lastMessageAt` | Time of the latest message or pill, or `null`. | | `directPeer` | For direct messages, the other person (`id`, `name`, `email`, `avatarUrl`). Otherwise `null`. | | `isMember` | Whether you're a member. In public channels you can read without being one; you become one when you post or mark the channel as read (the app does this when you open it). | | `unread` | Whether there's activity you haven't read. | ## List channels ```http GET /channels ``` Every public channel of the workspace, plus the private channels and direct messages you're in. Archived channels aren't listed. With an [AI agent's key](https://docs.fishcamp.space/docs/agents/api-keys#agent-keys), only the agent's channels are listed, and a channel the agent creates with `POST /channels` is added to them. ## Get a channel ```http GET /channels/:channelId ``` Returns one channel. A private channel you're not in answers `404`. ## Create a channel ```http POST /channels ``` | Field | Type | Required | Description | | ------------ | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | Yes | Up to 60 characters. Stored in lowercase, with hyphens for spaces and other punctuation dropped (`Release 2.5` → `release-25`). | | `topic` | string | No | Description, up to 250 characters. | | `visibility` | `PUBLIC` or `PRIVATE` | No | Default: `PUBLIC`. | ```bash curl -X POST "https://api.fishcamp.space/api/v1/channels" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"name": "release-2-5", "topic": "Checklist and status for release 2.5"}' ``` Returns `201 Created` with the channel. A name already used by another active channel of the workspace gets `409 conflict`. The creator becomes the channel's first member, and the timeline gets a "created #release-2-5" pill. ## Update a channel ```http PATCH /channels/:channelId ``` Send only the fields you want to change. | Field | Type | Who can change it | | ------------------------------------------- | ------- | -------------------------------------------------------------------------------------------- | | `name` | string | Admins and the channel's creator, as people: API keys can't rename. Not for direct messages. | | `topic` | string | Anyone who can see the channel. An empty string clears it. | | `voiceEnabled` | boolean | Anyone who can see the channel. `false` closes an open audio room. | | `cameraEnabled` | boolean | Anyone who can see the channel. | | `screenShareEnabled` | boolean | Anyone who can see the channel. | | `transcriptionEnabled` New in November 2026 | boolean | Turns [transcription](https://docs.fishcamp.space/docs/guides/transcription) on or off. | ```bash curl -X PATCH "https://api.fishcamp.space/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"cameraEnabled": false}' ``` Returns the updated channel. Renames and switch changes add a pill to the timeline, and switch changes apply right away to people already in the audio room. ## Archive a channel ```http DELETE /channels/:channelId ``` **Admins**, as people: API keys get `403`, even keys an admin created. Archives the channel: it disappears from the list and its audio room closes. Its messages stay stored, but every endpoint answers `404` for the channel afterwards, and there's no endpoint to unarchive it. Returns `204`. Direct messages can't be archived (`422`). ## Open a direct message ```http POST /channels/direct ``` | Field | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------------------------- | | `profileId` | string | Yes | The other person's profile ID, from [`GET /workspace/members`](https://docs.fishcamp.space/docs/api/workspaces#list-members). | Returns the direct message between you and that person, creating it the first time. There's only one per pair. ## Add someone to a channel ```http POST /channels/:channelId/members ``` | Field | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------- | | `profileId` | string | Yes | Profile ID of someone in the workspace. | Adds the person to a channel you can see (for private channels, that means one you're in) and returns the channel. Not for direct messages. ## Mark as read ```http POST /channels/:channelId/read ``` Marks everything in the channel as read for you. Returns `204`. --- # Message endpoints > Read a channel's messages page by page, send messages, edit and delete them through the REST API, and understand the message object and pills. Source: https://docs.fishcamp.space/docs/api/messages Paths are relative to `https://api.fishcamp.space/api/v1` and all need the `X-Workspace-Id` header. See the [REST API overview](https://docs.fishcamp.space/docs/api) for authentication and errors. ## The message object ```json { "id": "6c1f0f0e-8a7e-4f4e-9d55-0e6f6f1a2b3c", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "kind": "TEXT", "eventType": null, "authorType": "MEMBER", "author": { "id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes", "email": "maya@northstar.studio", "avatarUrl": null }, "authorName": null, "authorAvatarUrl": null, "content": "@Theo the build for 2.4 is green, can you check the release notes?", "payload": null, "embeds": [], "createdAt": "2026-11-05T14:32:08.000Z", "editedAt": null, "deletedAt": null, "clientId": null } ``` | Field | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `TEXT` for messages, `EVENT` for timeline pills. | | `eventType` | For pills, what happened (see [Pills](#pills)). `null` for messages. | | `authorType` | `MEMBER` (a person), `AGENT` (an API key), `WEBHOOK` (an incoming webhook) or `SYSTEM` (Fishcamp itself). New values may be added: show unknown ones like `SYSTEM`. | | `author` | The person who wrote it, or `null` when it wasn't a person. | | `authorName` | The name shown when there's no person, for example the webhook's name. | | `content` | The text, up to 4,000 characters. Empty for deleted messages. | | `payload` | Extra data: details of a pill (such as a call's duration) or, for webhook messages, the cards sent (also in `embeds`). `null` when there's none. | | `embeds` | Cards sent by [incoming webhooks](https://docs.fishcamp.space/docs/guides/incoming-webhooks#payload). | | `editedAt`, `deletedAt` | When the message was edited or deleted, or `null`. | ## List messages ```http GET /channels/:channelId/messages?limit=50&cursor=… ``` | Query | Description | | -------- | ------------------------------------------------------------------------------------------- | | `limit` | Messages per page, from 1 to 100. Default: 50. | | `cursor` | The `nextCursor` of the previous page, to get older messages. Treat it as an opaque string. | The first page has the most recent messages. Inside a page, messages go from oldest to newest, ready to display. ```json { "items": [{ "id": "…", "kind": "TEXT", "content": "…" }], "nextCursor": "MjAyNi0xMS0wNVQxNDozMjowOC4wMDBafDZjMWYwZjBl…" } ``` Keep passing `nextCursor` as `cursor` until it comes back `null`: ```bash curl "https://api.fishcamp.space/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/messages?limit=100" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" ``` Deleted messages stay in the list with `deletedAt` set and empty `content`, so a conversation keeps its shape. ## Send a message ```http POST /channels/:channelId/messages ``` | Field | Type | Required | Description | | ---------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------- | | `content` | string | Yes | 1 to 4,000 characters. Trailing spaces and line breaks are trimmed. | | `clientId` | string | No | Your own ID for the message (8 to 64 letters, digits, `_` or `-`). It comes back in the response, so you can match what you sent. | ```bash curl -X POST "https://api.fishcamp.space/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/messages" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"content": "Release 2.5 is out: https://example.com/releases/2.5"}' ``` Returns `201 Created` with the message. Everyone in the channel sees it right away. Messages sent with an API key need the `write` scope and show the key's name with an **AI** label. ## Edit a message ```http PATCH /messages/:messageId ``` | Field | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------ | | `content` | string | Yes | The new text, 1 to 4,000 characters. | Only the author can edit, and only text messages. Returns the message with `editedAt` set. Someone else's message gets `403 forbidden`. With an API key (`write` scope), "the author" means the key itself: a key edits only the messages it posted, and never the messages its creator wrote as a person. ## Delete a message ```http DELETE /messages/:messageId ``` The author can delete their own messages; admins and owners, as people, can delete any message. Returns `204`. Everyone sees "Message deleted" in its place. An API key deletes only the messages it posted itself, even when an admin created it: anyone else's message gets `403 forbidden`. ## Pills Pills are messages with `kind: "EVENT"`. Their `eventType` says what happened: | `eventType` | What happened | | --------------------------- | --------------------------------------------------------------------------------------------------------------- | | `channel.created` | The channel was created. | | `channel.renamed` | The channel was renamed (`payload.from`, `payload.to`). | | `channel.modality_changed` | Voice, camera or screen sharing was turned on or off (`payload.modality`, `payload.enabled`). | | `channel.member_added` | Someone was added to the channel, or joined it with an invite link (`payload.via` is `invite_link`). | | `call.started` | The first person joined: the audio room opened. | | `call.participant_joined` | Someone joined the audio room. For a guest, `payload.guest` is `true` and the name ends with "(guest)". | | `call.participant_left` | Someone left the audio room. | | `call.screen_share_started` | Someone started sharing their screen. | | `call.screen_share_stopped` | Someone stopped sharing their screen. | | `call.ended` | The audio room closed (`payload.durationMs`, `payload.participants`). | | `webhook.created` | An admin created an incoming webhook. | | `webhook.revoked` | An admin revoked an incoming webhook. | | `transcript.started` | Transcription started in the audio room. New in November 2026 | | `transcript.routed` | The transcript was saved, sent to agents or discarded, failed, or is waiting for a person. New in November 2026 | Pills can't be edited. Treat unknown `eventType` values as plain information: new ones may be added. --- # Audio room endpoints > See who is in a channel's audio room, list the rooms that are open, and get a token to join one with a WebRTC client. Source: https://docs.fishcamp.space/docs/api/audio-rooms Paths are relative to `https://api.fishcamp.space/api/v1` and all need the `X-Workspace-Id` header. See the [REST API overview](https://docs.fishcamp.space/docs/api) for authentication and errors. How rooms work for people is in [Audio rooms](https://docs.fishcamp.space/docs/guides/audio-rooms). ## Get the room ```http GET /channels/:channelId/call ``` Who's in the channel's audio room right now. ```json { "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "mediaSessionId": "e4d3c2b1-a0f9-4e8d-b7c6-5a4b3c2d1e0f", "startedAt": "2026-11-05T14:00:12.000Z", "participants": [ { "identity": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "profileId": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes", "screenSharing": true, "joinedAt": "2026-11-05T14:00:12.000Z" } ] } ``` When nobody is in the room, `mediaSessionId` and `startedAt` are `null` and `participants` is empty. New in November 2026 Guests who joined with a [meeting link](https://docs.fishcamp.space/docs/guides/meeting-links) are listed too: their `identity` starts with `guest_`, `profileId` is `null` and their name ends with "(guest)". People can take them out of the room with [`POST /channels/:id/call/guests/:identity/remove`](https://docs.fishcamp.space/docs/api/meeting-links#remove-a-guest). ## List open rooms ```http GET /calls/active ``` Every audio room with people in it, among the channels and direct messages you can see, in the same shape as above. ## Get a join token ```http POST /channels/:channelId/call/token ``` **People.** Returns what a WebRTC client needs to join the channel's room. The Fishcamp app calls it when someone chooses **Join audio**. API keys get `403 forbidden`, and guests join through [`POST /meet/:code/join`](https://docs.fishcamp.space/docs/api/meeting-links#join) instead. ```json { "url": "wss://…", "token": "eyJhbGciOi…", "roomName": "ch_0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10" } ``` * Fishcamp's media server is [LiveKit](https://livekit.io): any LiveKit client SDK can connect with `url` and `token`. * The token is short-lived: use it to connect within 10 minutes. * What the token allows follows the channel's switches: no camera when `cameraEnabled` is `false`, no screen sharing when `screenShareEnabled` is `false`. * Voice turned off in the channel: `409 voice_disabled`. Audio rooms not set up on the server: `503 media_unavailable`. * Limit: 20 per minute. --- # Meeting link endpoints > Create, replace and turn off a channel's meeting link, remove guests from the room, and the public routes a guest uses to join without an account. Source: https://docs.fishcamp.space/docs/api/meeting-links New in November 2026 Paths are relative to `https://api.fishcamp.space/api/v1`. The channel routes need the `X-Workspace-Id` header and a person: API keys get `403 forbidden` on all of them, reading the link included. The `/meet` routes are public: guests call them without an account. How meeting links work in the app is in [Meeting links for guests](https://docs.fishcamp.space/docs/guides/meeting-links). ## The meeting link object ```json { "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "code": "Hq7kT2mZpX4rNa", "url": "https://fishcamp.space/m/Hq7kT2mZpX4rNa", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "createdBy": { "id": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "name": "Maya Lopes" }, "guestJoins": 3, "lastUsedAt": "2026-11-05T15:04:02.000Z", "createdAt": "2026-11-05T14:55:00.000Z" } ``` | Field | Description | | ------------ | --------------------------------------------------- | | `code` | 14 letters and digits: the secret part of the link. | | `url` | The link to send to guests. | | `createdBy` | Who created it, or `null`. | | `guestJoins` | How many times guests joined with this link. | | `lastUsedAt` | When a guest last joined with it, or `null`. | ## Get the link ```http GET /channels/:channelId/meeting-link ``` **People** who can see the channel. The channel's active link, or `null` when it has none. ## Create or copy the link ```http POST /channels/:channelId/meeting-link ``` **People** who can see the channel. Returns `200` with the channel's active link, and creates it the first time. A channel has one link at a time, so calling this again returns the same link. ```bash curl -X POST "https://api.fishcamp.space/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/meeting-link" \ -H "Authorization: Bearer YOUR_SESSION_TOKEN" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" ``` | Error | When | | ---------------------- | ---------------------------------------------- | | `409 voice_disabled` | The channel's voice is off. | | `422 validation_error` | It's a direct message. | | `404 not_found` | You can't see the channel, or it was archived. | ## Replace the link ```http POST /channels/:channelId/meeting-link/reset ``` **People.** Turns off the current link and returns a new one (`200`). The old code stops working at once, and the guests in the room are taken out of it. The channel's voice must be on (`409 voice_disabled` otherwise). ## Turn off the link ```http DELETE /channels/:channelId/meeting-link ``` **People.** Returns `204`, or `404` when the channel has no active link. The guests in the room are taken out of it, and guests who open the old link get `410 meeting_link_revoked`. ## Remove a guest from the room ```http POST /channels/:channelId/call/guests/:identity/remove ``` **People** who can see the channel. Takes a guest out of the audio room and returns `204`. A guest's `identity` starts with `guest_`: find it in [`GET /channels/:id/call`](https://docs.fishcamp.space/docs/api/audio-rooms#get-the-room). | Error | When | | ----------------------- | ---------------------------------------------------------------------------------- | | `422 validation_error` | The identity isn't a guest's. People from the workspace can't be removed this way. | | `404 not_found` | That guest isn't in the room. | | `503 media_unavailable` | The audio rooms didn't answer. Try again in a moment. | The link keeps working. To keep the guest out, replace it. ## Public routes for guests The guest page at `https://fishcamp.space/m/` calls these routes for you. Use them only to build your own guest client. ### Preview a meeting ```http GET /meet/:code ``` No token. Limit: 60 per minute. ```json { "workspaceName": "Northstar Studio", "channelName": "design-crit", "status": "active", "hostPresent": true, "participantCount": 2, "cameraEnabled": true, "screenShareEnabled": true, "transcriptionEnabled": true } ``` | Field | Description | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `active`; `revoked` when the link was turned off or replaced; `unavailable` when the channel's voice is off or the channel was archived. | | `hostPresent` | Whether someone from the workspace is in the room. Guests can only join while it's `true`. | | `participantCount` | Everyone in the room now, guests included. | | `cameraEnabled`, `screenShareEnabled` | What guests may use, following the channel's switches. | | `transcriptionEnabled` | Whether what guests say is transcribed. Tell them before they join. | An unknown code answers `404`. ### Join ```http POST /meet/:code/join ``` No token. Limit: 10 per minute. | Field | Type | Required | Description | | ------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `name` | string | Yes | How the team sees the guest. Control characters are removed, spaces are collapsed and the name is cut to 40 characters. | ```bash curl -X POST "https://api.fishcamp.space/api/v1/meet/Hq7kT2mZpX4rNa/join" \ -H "Content-Type: application/json" \ -d '{"name": "Ana Souza"}' ``` ```json { "url": "wss://…", "token": "eyJhbGciOi…", "roomName": "ch_0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "identity": "guest_q7Rk2mTzX9pLw4Hn", "name": "Ana Souza", "guestToken": "eyJhbGciOi…", "expiresAt": "2026-11-05T21:04:02.000Z", "cameraEnabled": true, "screenShareEnabled": true, "transcriptionEnabled": true } ``` * Connect to the room with any [LiveKit](https://livekit.io) client SDK, using `url` and `token`, within 10 minutes. The token only allows what the channel's switches allow, and never data messages. * Fishcamp checks the guest again when they connect: if nobody from the workspace is in the room, the link was turned off, or the room already has 20 guests, the guest is taken out. * When the last person from the workspace leaves, the room closes for the guests too. * `guestToken` sends the guest's audio to transcription (below). It lasts 6 hours, until `expiresAt`, and opens nothing else: the rest of the API answers `401` to it. | Error | When | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `409 waiting_for_host` | Nobody from the workspace is in the room yet. Check `GET /meet/:code` every few seconds and try again when `hostPresent` is `true`. | | `409 meeting_full` | The room already has 20 guests. | | `409 meeting_unavailable` | The channel's voice is off, or the channel was archived. | | `410 meeting_link_revoked` | The link was turned off or replaced. | | `422 validation_error` | The name is missing or empty. | | `503 media_unavailable` | Audio rooms aren't available on this server. | ### Send audio for transcription ```http POST /meet/:code/transcript?startedAt=1793891052000&durationMs=15000 ``` Sends a chunk of the guest's own microphone while they're in the room and the channel's transcription is on. Fishcamp turns it into text and discards the audio. The last chunk still counts if it arrives up to 18 seconds after the guest left. Limit: 12 per minute. * **Header:** `Authorization: Bearer `. * **Body:** the raw audio, from 200 bytes to 1 MB, with a `Content-Type` of `audio/webm`, `audio/ogg`, `audio/mp4`, `audio/mpeg` or `audio/wav`. * **Query:** `startedAt`, when the chunk started, in Unix milliseconds (within 24 hours of now), and `durationMs`, from 500 to 120,000. The answer is the new transcript line, with the guest's name marked, or `{ "skipped": true }` when the chunk had no speech: ```json { "id": "b7a6c5d4-e3f2-4a1b-9c8d-7e6f5a4b3c2d", "mediaSessionId": "e4d3c2b1-a0f9-4e8d-b7c6-5a4b3c2d1e0f", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "profileId": null, "speakerName": "Ana Souza (guest)", "startMs": 240000, "endMs": 255000, "text": "Send me the preview link when it's ready." } ``` | Error | When | | ----------------------------------------------------------- | ------------------------------------------------------------------------ | | `401 unauthorized` | The guest token is missing, invalid or expired. | | `410 meeting_link_revoked` | The link was turned off or replaced after the guest joined. | | `403 forbidden` | The guest isn't in the room, and didn't leave it in the last 18 seconds. | | `409 transcription_off` | The channel's transcription is off. | | `409 no_audio_room` | No audio room is open in the channel. | | `409 transcript_closed` | This room's transcript is already closed. | | `422 validation_error` | The body, its type, `startedAt` or `durationMs` isn't valid. | | `502 transcription_failed`, `503 transcription_unavailable` | Speech-to-text failed for this chunk, or isn't available right now. | --- # Webhook endpoints > Create, list and revoke a channel's incoming webhooks through the REST API, and post through one with a Discord-compatible payload. Source: https://docs.fishcamp.space/docs/api/webhooks Paths are relative to `https://api.fishcamp.space/api/v1`. Managing webhooks needs the `X-Workspace-Id` header and an admin; posting through one needs neither. The payload, limits and setup for Uptime Kuma and Dokploy are in the [Incoming webhooks guide](https://docs.fishcamp.space/docs/guides/incoming-webhooks). ## List webhooks ```http GET /channels/:channelId/webhooks ``` **Admins.** The channel's active webhooks, oldest first. Secrets are never returned: you get the last four characters of the token. ```json [ { "id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "name": "Uptime Kuma", "tokenLast4": "Xq7k", "createdAt": "2026-11-04T09:12:00.000Z", "lastUsedAt": "2026-11-05T14:30:02.000Z" } ] ``` ## Create a webhook ```http POST /channels/:channelId/webhooks ``` **Admins.** | Field | Type | Required | Description | | ------ | ------ | -------- | ----------------------------------------------------------------- | | `name` | string | Yes | 1 to 60 characters. Shown as the author of the messages it posts. | Returns `201 Created` with the webhook and its full `url`. **The URL is in this response only**: store it right away. Direct messages can't have webhooks (`422`). ```json { "id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "name": "Uptime Kuma", "tokenLast4": "Xq7k", "createdAt": "2026-11-05T15:00:00.000Z", "lastUsedAt": null, "url": "https://api.fishcamp.space/api/v1/webhooks/inbound/whk_…Xq7k" } ``` ## Revoke a webhook ```http DELETE /channels/:channelId/webhooks/:webhookId ``` **Admins.** The URL stops working at once (later posts get `404`). Returns `204`. The timeline gets a pill saying who revoked it. ## Post to a webhook ```http POST /webhooks/inbound/:token ``` No `Authorization` header: the token in the URL is the secret. Body: `content` (or `text`), `username` and up to 10 `embeds`, like a Discord webhook. | Answer | When | | ----------------------------------- | -------------------------------------------------------- | | `204` | Posted. | | `200` with `{ "id", "channel_id" }` | Posted, and you added `?wait=true`. | | `404` | Unknown or revoked token, or archived channel. | | `422` | Nothing to post, or a field breaks a limit. | | `429` | More than 60 requests in a minute from the same address. | Full payload reference and examples: [Incoming webhooks](https://docs.fishcamp.space/docs/guides/incoming-webhooks#payload). --- # API key endpoints > List, create and revoke a workspace's cwk_ API keys through the REST API. Admins only, and always done by a person. Source: https://docs.fishcamp.space/docs/api/api-keys New in November 2026 Paths are relative to `https://api.fishcamp.space/api/v1` and all need the `X-Workspace-Id` header. These are admin actions: they must be made by a person, and requests authenticated with an API key get `403 forbidden`. What keys can do is in [API keys](https://docs.fishcamp.space/docs/agents/api-keys). ## List API keys ```http GET /workspace/api-keys ``` **Admins.** The workspace's keys with their name, scopes, when they were created and when they were last used. The keys themselves are never returned. ## Create an API key ```http POST /workspace/api-keys ``` **Admins.** | Field | Type | Required | Description | | -------- | ------ | -------- | ------------------------------------------------------------------------------------ | | `name` | string | Yes | Who or what uses the key, for example `Release bot`. Shown on the messages it posts. | | `scopes` | array | Yes | `["read"]`, or `["read", "write"]` to also post messages and create channels. | ```bash curl -X POST "https://api.fishcamp.space/api/v1/workspace/api-keys" \ -H "Authorization: Bearer YOUR_SESSION_TOKEN" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"name": "Release bot", "scopes": ["read", "write"]}' ``` Returns `201 Created` with the new key. **The full key (`cwk_…`) is in this response only**: Fishcamp stores a hash of it and can't show it again. ## Revoke an API key ```http DELETE /workspace/api-keys/:keyId ``` **Admins.** The key stops working at once: the next request made with it gets `401 unauthorized`. Returns `204`. --- # Agent endpoints > List, create, update and delete AI agents, rotate their API key and webhook secret, and send a test event through the REST API. Source: https://docs.fishcamp.space/docs/api/agents New in November 2026 Paths are relative to `https://api.fishcamp.space/api/v1` and all need the `X-Workspace-Id` header and a person: requests with an API key get `403 forbidden` on every agent route, reading included. Everyone in the workspace can list and read agents; creating, changing and deleting them are admin actions. What agents are and how they work is in [AI agents](https://docs.fishcamp.space/docs/agents), and the events they get are in [Agent webhooks](https://docs.fishcamp.space/docs/agents/webhooks). ## The agent object ```json { "id": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "name": "Scout", "role": "Lists what to track after a meeting", "instructions": "Suggest a task only when someone agrees on an owner and a date.", "autonomy": "PROPOSE", "events": ["message.created", "transcript.handoff"], "channelIds": ["0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10"], "webhookUrl": "https://scout.example.com/webhook", "status": "ACTIVE", "apiKeyPrefix": "cwk_8Hq2kL7x", "createdAt": "2026-11-05T14:00:00.000Z", "updatedAt": "2026-11-05T14:00:00.000Z", "lastDeliveryAt": "2026-11-05T15:26:03.000Z", "lastDeliveryStatus": 202, "lastError": null, "deliveriesOk": 18, "deliveriesFailed": 0 } ``` | Field | Description | | --------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `role` | What it does, up to 120 characters. | | `instructions` | How it should work, up to 4,000 characters. Sent with transcript handoffs. | | `autonomy` | `OBSERVE`, `PROPOSE`, `ACT_LOW_RISK` or `AUTONOMOUS`. See [autonomy](https://docs.fishcamp.space/docs/agents#autonomy). | | `events` | The events sent to its webhook. See [Events](https://docs.fishcamp.space/docs/agents/webhooks#events). | | `channelIds` | The channels it works in. Its key only reaches these. | | `webhookUrl` | A public `https` address, or `null`. | | `status` | `ACTIVE`, or `PAUSED` when it gets no events. | | `apiKeyPrefix` | The first characters of its active key, or `null` when the key was revoked. | | `lastDeliveryAt`, `lastDeliveryStatus`, `lastError` | The last delivery, its HTTP status and its error, if any. | | `deliveriesOk`, `deliveriesFailed` | How many deliveries worked and failed. | The API key and the webhook secret themselves are never in this object. ## List agents ```http GET /agents GET /agents/:agentId ``` **People.** The workspace's agents, sorted by name, or one agent. For members who aren't admins, `webhookUrl` is `null`, `lastError` only says `Delivery failed`, and `channelIds` lists only the channels they can see. ## Create an agent ```http POST /agents ``` **Admins.** | Field | Type | Required | Description | | -------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `name` | string | Yes | 1 to 40 characters, unique in the workspace. | | `role` | string | Yes | What it does, 1 to 120 characters. | | `instructions` | string | No | How it should work, up to 4,000 characters. | | `autonomy` | string | Yes | `OBSERVE`, `PROPOSE`, `ACT_LOW_RISK` or `AUTONOMOUS`. | | `channelIds` | array | No | Up to 100 IDs of channels you can see: public channels, or private channels you're in. Not direct messages. | | `webhookUrl` | string or `null` | No | A public `https` address without a user or password, up to 2,048 characters. | | `events` | array | No | Any of `message.created`, `audio.started`, `audio.ended`, `transcript.routed` and `transcript.handoff`. | ```bash curl -X POST "https://api.fishcamp.space/api/v1/agents" \ -H "Authorization: Bearer YOUR_SESSION_TOKEN" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "Scout", "role": "Lists what to track after a meeting", "autonomy": "PROPOSE", "channelIds": ["0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10"], "webhookUrl": "https://scout.example.com/webhook", "events": ["message.created", "transcript.handoff"] }' ``` Returns `201 Created` with `agent` (the agent object), `apiKey` (`cwk_…`) and, when you gave a webhook URL, `webhookSecret` (`whsec_…`). **The key and the secret are in this response only.** | Error | When | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `409 conflict` | Another agent already has that name. | | `422 validation_error` | A field is invalid: for example, a webhook URL that isn't public `https`, a channel from another workspace, or a private channel you're not in. | ## Update an agent ```http PATCH /agents/:agentId ``` **Admins.** Send only the fields you want to change: the same ones as above, plus `status` (`ACTIVE` or `PAUSED`). Returns the agent. * A new `autonomy` or `name` applies to the agent's key at once. * `"webhookUrl": null` removes the webhook and its secret. * Adding a webhook URL to an agent that had none creates a secret, but this response doesn't include it. Call [rotate-secret](#rotate-the-webhook-secret) to get one. ## Rotate the API key ```http POST /agents/:agentId/rotate-key ``` **Admins.** Revokes the agent's key and returns `agent` and the new `apiKey` (`200`). The new key acts as the admin who rotated it. ## Rotate the webhook secret ```http POST /agents/:agentId/rotate-secret ``` **Admins.** Returns `agent` and the new `webhookSecret` (`200`). Deliveries are signed with it right away. An agent without a webhook URL gets `409 no_webhook`. ## Send a test event ```http POST /agents/:agentId/test ``` **Admins.** Sends a [`ping`](https://docs.fishcamp.space/docs/agents/webhooks#ping) to the agent's webhook and returns what happened (`200`): ```json { "ok": true, "status": 202, "error": null } ``` An agent without a webhook URL gets `409 no_webhook`. ## Delete an agent ```http DELETE /agents/:agentId ``` **Admins.** Revokes the agent's key and deletes the agent: it gets no more events. Its messages stay in the channels. Returns `204`. --- # Transcript endpoints > List a channel's transcripts, read one with every line and its speaker, and choose where it goes, through the REST API. Source: https://docs.fishcamp.space/docs/api/transcripts New in November 2026 Paths are relative to `https://api.fishcamp.space/api/v1` and all need the `X-Workspace-Id` header. API keys need the `read` scope to read transcripts; choosing where one goes needs a person. How transcription and routing work is in [Transcription and AI routing](https://docs.fishcamp.space/docs/guides/transcription). ## The transcript object ```json { "id": "3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b", "channelId": "0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10", "startedAt": "2026-11-05T15:00:12.000Z", "endedAt": "2026-11-05T15:25:40.000Z", "status": "DONE", "action": "run_devops_agent", "confidence": null, "routedBy": "7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b", "routedAt": "2026-11-05T15:26:03.000Z", "segmentCount": 48, "participants": ["Maya Lopes", "Theo Park", "Ana Souza (guest)"], "result": { "executionId": null, "agents": [{ "agentId": "e5c4b3a2-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "name": "Scout", "ok": true, "status": 202, "error": null }] } } ``` | Field | Description | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | The transcript's ID. One transcript per audio room session. | | `status` | `ACTIVE` while the room is open and being transcribed, `PROCESSING` while Dcision decides, then `DONE` or `FAILED`. | | `action` | `discard`, `save_to_opensync`, `run_devops_agent` (**Send to agents**), or `review` while it waits for a person. | | `confidence` | How sure Dcision was, from 0 to 1, or `null` when a person decided. | | `routedBy` | `dcision`, `system` (nothing was transcribed), or the profile ID of the person who chose. | | `participants` | Everyone who spoke. Guests have "(guest)" after their name. | | `result` | What happened: `opensync.path` and `opensync.spaceId` after a save; `agents`, one item per agent with `agentId`, `name`, `ok`, `status` and `error`, after a handoff; `error` when it failed; `reason` when it's waiting for a person, for example because a guest spoke and Dcision picked agents. | [Read a transcript](#read-a-transcript) adds `segments`: every line with its `speakerName`, `startMs` and `endMs` (milliseconds since the room opened) and `text`. ## List transcripts ```http GET /channels/:channelId/transcripts ``` The 20 latest transcripts of the channel's audio rooms, newest first: when each call happened, who spoke, and what was decided for it (discarded, saved to OpenSync, sent to agents, or waiting for a person). A channel you can't see answers `404`. ```bash curl "https://api.fishcamp.space/api/v1/channels/0b6f3c55-2a51-4e0a-a3d6-6a3f1f2e9d10/transcripts" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" ``` ## Read a transcript ```http GET /transcripts/:transcriptId ``` One transcript: every line with the name of who said it, plus the routing decision. Use the IDs from the list above. A transcript from a channel you can't see answers `404`. ```bash curl "https://api.fishcamp.space/api/v1/transcripts/3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b" \ -H "Authorization: Bearer cwk_YOUR_KEY" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" ``` Transcripts contain text only: the audio is never stored, so there's nothing to download but the words. AI agents can read the same data with the MCP tools [`list_transcripts`](https://docs.fishcamp.space/docs/agents/mcp#list_transcripts) and [`read_transcript`](https://docs.fishcamp.space/docs/agents/mcp#read_transcript). ## Choose where a transcript goes New in November 2026 ```http POST /transcripts/:transcriptId/route ``` **People** who can see the channel: requests with an API key get `403`. Use it to decide a transcript that's waiting for a person, to change Dcision's choice, or to try a failed action again. | `action` | What happens | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `discard` | Marks it as discarded. What was already saved or sent stays where it went. | | `save_to_opensync` | Saves it as a note in the workspace's [OpenSync space](https://docs.fishcamp.space/docs/guides/opensync). | | `run_devops_agent` | **Send to agents**: delivers it to the channel's agents that take [transcript handoffs](https://docs.fishcamp.space/docs/agents/webhooks#transcript-handoff). | ```bash curl -X POST "https://api.fishcamp.space/api/v1/transcripts/3f2d9c1e-5b7a-4c2e-9a0b-6d8e1f4a2c7b/route" \ -H "Authorization: Bearer YOUR_SESSION_TOKEN" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"action": "run_devops_agent"}' ``` Returns `200` with the transcript, without its lines. When the action itself fails, for example because OpenSync isn't connected or no agent takes handoffs, the answer is still `200`, with `"status": "FAILED"` and the reason in `result.error`. The timeline gets a line with the result either way. | Error | When | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `409 transcript_busy` | The room is still open or just closed, Dcision is still deciding, or someone else's choice is running. Only one choice runs at a time. | | `409 transcript_empty` | Nothing was transcribed. | | `422 validation_error` | `action` isn't one of the three above. | --- # Integration endpoints > Read a workspace's integrations, connect OpenSync with a space token, test the connection and disconnect it through the REST API. Source: https://docs.fishcamp.space/docs/api/integrations New in November 2026 Paths are relative to `https://api.fishcamp.space/api/v1` and all need the `X-Workspace-Id` header. Everyone in the workspace can read the integrations; connecting, testing and disconnecting are admin actions that must be made by a person (requests with an API key get `403 forbidden`). Setup in the app: [OpenSync integration](https://docs.fishcamp.space/docs/guides/opensync). ## The integration object ```json { "provider": "opensync", "connected": true, "settings": { "baseUrl": "https://api.opensync.space", "spaceId": "space-123", "folder": "Fishcamp/Meeting notes" }, "tokenHint": "…Q7xk", "lastStatus": "ok", "lastError": null, "lastUsedAt": "2026-11-05T15:31:04.000Z", "updatedAt": "2026-11-05T14:10:00.000Z" } ``` | Field | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `provider` | `opensync`, the only provider for now. | | `connected` | Whether the workspace is connected. When it's `false`, the other fields are `null`. | | `settings` | OpenSync's address, the space ID and the folder for notes. | | `tokenHint` | The last four characters of the token, for admins only; `null` for everyone else. The token itself never comes back. | | `lastStatus`, `lastError` | The result of the last save or test, `ok` or `error`, and why it failed. | | `lastUsedAt` | When a note was last saved, or `null`. | ## List integrations ```http GET /integrations ``` Every provider, connected or not. Anyone in the workspace, and API keys with the `read` scope. ## Connect OpenSync ```http PUT /integrations/opensync ``` **Admins.** Connects the workspace to an OpenSync space, or updates the connection. Fishcamp checks the token with OpenSync first and saves nothing if that fails. | Field | Type | Required | Description | | --------- | ------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spaceId` | string | Yes | The space ID, or the space link copied from the browser (`…/spaces//…`). | | `token` | string | To connect | The space token, `osk_…`. When updating with the same `spaceId` and `baseUrl`, leave it out to keep the stored one. Changing either needs the token again (`422` otherwise). | | `folder` | string | No | Folder for notes inside the space: up to 120 characters, with letters, numbers, spaces, `-`, `_`, `.` and `/`, and no `.` or `..` parts. Default: `Fishcamp/Meeting notes`. | | `baseUrl` | string | No | OpenSync's address: public `https`, without a path or query (`/api` at the end is accepted and dropped). Default: `https://api.opensync.space`. | ```bash curl -X PUT "https://api.fishcamp.space/api/v1/integrations/opensync" \ -H "Authorization: Bearer YOUR_SESSION_TOKEN" \ -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \ -H "Content-Type: application/json" \ -d '{"spaceId": "https://opensync.space/spaces/space-123", "token": "osk_YOUR_SPACE_TOKEN"}' ``` Returns `200` with the integration. | Error | When | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `422 integration_rejected` | OpenSync refused: a wrong or revoked token, an unknown space or a rate limit. `message` says which. | | `422 validation_error` | A field is invalid, for example a token that doesn't start with `osk_`, an address that isn't public `https`, a folder with `..`, or a new space or address without the token. | | `502 integration_unreachable` | OpenSync didn't answer, or its address leads to a private network. | ## Test the connection ```http POST /integrations/:provider/test ``` **Admins.** Asks OpenSync again with the stored token and returns the integration, with `lastStatus` set to `ok` or `error` (the answer is `200` either way). Not connected: `404`. ## Disconnect ```http DELETE /integrations/:provider ``` **Admins.** Deletes the connection and the stored token. Returns `204`, or `404` when it isn't connected. Notes already saved in OpenSync stay there. --- # Security and privacy > How Fishcamp protects sign-in, secrets, invites, guests, agents, audio and transcripts. Hashed tokens, deny-by-default rooms, no stored audio and HTTPS. Source: https://docs.fishcamp.space/docs/security ## Signing in * You sign in with **Google** or with a **6-digit code** sent to your email. There are no Fishcamp passwords to leak or reuse. * A code works for **10 minutes**, only the latest one is valid, and it allows **5 attempts**. After three codes in 10 minutes you have to wait. * Codes are stored as keyed hashes, and asking for one gives the same answer whether or not the email has an account. ## Secrets are stored as hashes API keys (`cwk_…`), incoming webhook URLs (`whk_…`) and email invite links (`inv_…`) are random secrets. Fishcamp shows each one **once**, when it's created, and stores only a hash of it. Lists show what each secret is for, never the secret itself. If one leaks, revoke it and create a new one. ## Secrets Fishcamp has to use are encrypted New in November 2026 An AI agent's webhook signing secret (`whsec_…`) and an OpenSync space token (`osk_…`) are stored encrypted, because Fishcamp needs them to sign deliveries and to save notes. The API never returns them: the webhook secret is shown once, when the agent is created or the secret is rotated, and admins see only the last four characters of the OpenSync token. ## Invite links and meeting links are meant to be shared New in November 2026 Invite links (`fishcamp.space/join/…`) and meeting links (`fishcamp.space/m/…`) are made to be passed around, so they can be copied again later. Treat them like keys to a door: * **An invite link** lets anyone who has it join as a **Member**, until it expires (30 days by default), reaches its limit of uses or an admin turns it off. Links to a private channel only show up for admins who are in it. * **A meeting link** only opens one channel's audio room, and only while someone from the workspace is in it. **New link** and **Turn off link** close it at once and take the guests out of the room. * Their pages aren't indexed by search engines, and they don't pass their address on to the sites they link to. ## Email invites belong to one address * An email invite only works for the address it was sent to: someone signed in with a different email can't accept it. * It expires after **7 days**, and admins can revoke it any time before it's accepted. * Sending a new invite to the same address replaces the old link. ## Workspaces and channels deny by default * **Every request is checked against the workspace.** You only reach workspaces you're a member of, and IDs from another workspace answer "not found". * **Private channels and direct messages are invisible to outsiders.** People who aren't in them get "not found", not "forbidden", so the API never confirms they exist. * **Live updates follow the same rules.** The app only receives messages, pills and audio room changes for the channels you can see. * **Audio rooms**: a token to join is only issued to someone who can see the channel, it's short-lived, and it only allows the media the channel has switched on. * **API keys never act as admins.** Even a key an admin created can't invite people, change roles, archive or rename channels, delete other people's messages, or manage invite links, webhooks, API keys, AI agents and integrations, so an agent can't change who has access. It edits and deletes only the messages it posted, and every read needs its `read` scope. ## Guests only get the call New in November 2026 People who join with a [meeting link](https://docs.fishcamp.space/docs/guides/meeting-links) have no account and no access to the workspace: * They get the audio room of one channel, and only while someone from the workspace is in it: Fishcamp checks it as they connect, and when the last person from the workspace leaves, the meeting ends for them. They can't read messages, see other channels or list people. * Their token opens the call and, when the channel has transcription on, sends their own audio to be transcribed. The rest of the API refuses it, and guests can't send data messages in the room. * A room takes at most 20 guests. Anyone in the channel can remove one, and guests always show a **Guest** label. * When a guest spoke, Dcision never sends the transcript to AI agents on its own: a person confirms first. ## AI agents stay in their channels New in November 2026 * **An agent's API key only reaches the agent's channels**, in the REST API and the MCP server. See [API keys](https://docs.fishcamp.space/docs/agents/api-keys#agent-keys). * **Agents follow the admin behind them.** An admin can only connect an agent to channels they can see, and an agent only gets events while its key is active and that admin is still in the workspace and can see the channel. * **Webhook deliveries are signed** with HMAC-SHA256 and a timestamp, so an agent can check they come from Fishcamp and refuse old ones. See [Agent webhooks](https://docs.fishcamp.space/docs/agents/webhooks#signature). * **Fishcamp only calls public `https` addresses** for agent webhooks and OpenSync. Addresses with a user or password, local names and private networks (IPv4 and IPv6) are refused. The address is checked as Fishcamp connects, so a DNS change can't send a call into a private network, and redirects aren't followed. * **The OpenSync token stays with its space.** Changing the space or the OpenSync address needs the token pasted again. ## Audio is never stored * Audio rooms aren't recorded. * With [transcription](https://docs.fishcamp.space/docs/guides/transcription) on, speech is turned into text as it happens and only the text is kept. Everyone in the room sees **Transcribing** while it runs. * Audio and video travel encrypted between browsers and the media server, as WebRTC requires (DTLS-SRTP). ## HTTPS everywhere The app (`fishcamp.space`), the API (`api.fishcamp.space`) and these docs (`docs.fishcamp.space`) are only served over HTTPS. The app and the docs also send Strict-Transport-Security, so browsers never fall back to plain HTTP. ## Smaller things that matter * **Images from webhooks aren't loaded.** A webhook's `avatar_url` is ignored, because loading images from other sites would reveal readers' IP addresses to them. * **Links open safely.** Links in messages open in a new tab without passing on where you came from. * **Rate limits** slow down guessing and floods. See [REST API → Rate limits](https://docs.fishcamp.space/docs/api#rate-limits). * **Messages from software are labeled.** Webhook messages show an **App** label and API key messages an **AI** label, so nobody mistakes them for a person. --- # FAQ > Quick answers about browsers, audio rooms, guests, transcription, private channels, invites, webhooks, API keys, AI agents and these docs. Source: https://docs.fishcamp.space/docs/faq ## Getting started No. Fishcamp runs in the browser, on a computer or a phone. Open [fishcamp.space](https://fishcamp.space) and sign in with Google or an email code. Current versions of Chrome, Edge, Firefox and Safari. Sharing your screen needs a desktop browser, and sharing sound with your screen only works in Chrome and Edge. See [Audio rooms → Browser support](https://docs.fishcamp.space/docs/guides/audio-rooms#browser-support). ## Calls and transcription No. Audio rooms aren't recorded. If a channel has transcription turned on, Fishcamp keeps the text of what was said, never the audio, and everyone in the room sees **Transcribing** while it runs. An AI decision reads the transcript when the audio room closes and picks one action: discard it, save it to OpenSync or send it to the channel's AI agents. When it isn't confident, it waits for a person. Anyone in the channel can change the choice from the transcript with **Change destination**. See [Transcription and AI routing](https://docs.fishcamp.space/docs/guides/transcription). Yes. Choose **Meeting link** in the channel and send the link. Your guest types their name and joins that channel's audio room while someone from your workspace is in it; when the last of you leaves, the meeting ends for them. Guests never see the channel's messages. See [Meeting links for guests](https://docs.fishcamp.space/docs/guides/meeting-links). To the OpenSync space an admin connected in **Settings** → **Integrations**, as one Markdown note per call in the folder chosen there (`Fishcamp/Meeting notes` by default). Each workspace connects its own space. See [OpenSync integration](https://docs.fishcamp.space/docs/guides/opensync). ## Channels and people An admin chooses **+** → **Invite people** and shares the invite link. Anyone with it can join as a member for 30 days, unless you change its expiry or limit its uses. A link made from a channel also puts people in that channel. See [People and roles](https://docs.fishcamp.space/docs/guides/people-and-roles#invite-links). Only the people in it. Anyone already in the channel can add someone from the workspace. People outside it can't find it, and the API answers "not found" for it. An admin opens **People**, finds the person under **Members** and chooses **Remove**. They lose access to the workspace and leave its channels; their past messages stay. If they're in an audio room at that moment, they stay connected until they leave it. Only owners can remove another owner. It leaves the channel list, its audio room closes and its incoming webhooks stop posting. Its messages stay stored, but nobody can open the channel anymore, and archiving can't be undone from the app or the API. Only admins can archive channels. ## Integrations and AI Yes. Create an [incoming webhook](https://docs.fishcamp.space/docs/guides/incoming-webhooks) in the channel that should get the alerts and paste its URL where the tool expects a Discord webhook URL. Uptime Kuma and Dokploy work this way without changes. No: Fishcamp only keeps a hash, so nobody can show it again. Create a new one, update the tool that uses it, and revoke the old one. No. Those are admin actions and they need a person: an API key never acts as an admin, even one an admin created. An agent with a `write` key can post messages and create channels, and edit or delete only the messages it posted. Its messages carry an **AI** label. No. An agent added under **AI agents** gets a key that only reaches the channels you picked for it, in the REST API and the MCP server. A workspace key from **Settings** → **API keys** sees what the admin who created it sees. See [API keys](https://docs.fishcamp.space/docs/agents/api-keys#agent-keys). Any MCP client that can send an `Authorization` header: Claude Code, Cursor, VS Code, and Claude Desktop through `mcp-remote`, among others. Connectors that only sign in with OAuth, such as ChatGPT's, can't use API keys. See [MCP server](https://docs.fishcamp.space/docs/agents/mcp). Yes. Add `.md` to any page address to get its Markdown, read [/llms.txt](https://docs.fishcamp.space/llms.txt) for the index, or [/llms-full.txt](https://docs.fishcamp.space/llms-full.txt) for everything in one file.