Extensions
Build your own add-ons for Odyn.
An extension adds something to Odyn: a tool the agent can use, a small page above the message box, or both. You build it with @odyn-app/api, Odyn’s free building kit, and publish it through the public odyn-extensions repo. Once it’s merged, anyone can install it from Odyn’s Marketplace.
01 / Contents
02 / Getting started
An extension is a folder with a package.json whose odyn section describes it (its “manifest”), plus your code. The odyn-extension tool, which comes with the kit, starts one, rebuilds it as you work and publishes it. You don’t need to clone anything.
Prefer to build with the agent? In Odyn, open Marketplace → Extensions → New extension. Odyn makes the folder, installs it and opens a chat working in it, so you can skip step 1.
1. Start one
bunx --package @odyn-app/api odyn-extension create "Standup notes"cd standup-notesbun installThis makes a working extension with one command. Its manifest looks like this; owners are the GitHub usernames of the people who look after it.
{ "name": "odyn-extension-standup-notes", "version": "1.0.0", "private": true, "dependencies": { "@odyn-app/api": "^0.6.1" }, "odyn": { "id": "standup-notes", "title": "Standup notes", "description": "Turns today's chats into a standup.", "author": "Your name", "owners": ["your-github-username"], "apiVersion": 4, "permissions": ["chats:read", "agent:tools"], "agent": { "summary": "Writes a standup from today's chats." }, "commands": [ { "name": "write-standup", "title": "Write standup", "placement": "agentTool", "description": "Lists the chats worked on today." } ] }}2. Write the code
Put it in src/index.ts. Each command in the manifest gets a function with the same name.
import { defineExtension } from '@odyn-app/api'const ONE_DAY_MS = 24 * 60 * 60 * 1000defineExtension({ commands: { 'write-standup': async ({ odyn }) => { const chats = await odyn.chats.list() const today = chats.filter( (chat) => Date.now() - new Date(chat.updatedAt).getTime() < ONE_DAY_MS, ) return today.map((chat) => `- ${chat.title}`).join('\n') }, },})3. Try it in Odyn
bun run devThis rebuilds on every save. In Odyn, open Marketplace → Extensions → Install from folder and pick the extension’s folder. Odyn reloads it after each rebuild. If a rebuild asks for a new permission, Odyn leaves it off and asks you to allow it.
4. Publish
bun run publishThis sends your extension to the odyn-extensions repo as a pull request (a proposed change). Once it’s reviewed and merged, it’s released to everyone. The contribution guide has the details.
If you built the extension in Odyn, you can also publish from its workspace: the button above the Changes list says Publish to Marketplace, then In review, Send changes, or Publish with the next version number, depending on where your extension is.
03 / What extensions can do
- Give the agent tools. A command placed as an agent tool becomes a tool that Claude, Codex and Cursor can call in any chat, with inputs you define.
- Show a page above the message box. Your own small web page, opened from a button in the message box. It can save things per chat and attach a picture to the message.
- Show a page in its settings. A page drawn inside the extension’s settings page.
- Have settings. Text, password and checkbox fields the user fills in. Passwords are kept in the Mac keychain.
- Read chats and projects, start the agent in a chat, reach websites you list, and show a short message (a “toast”) in Odyn. Each needs the matching permission.
- Look like Odyn. Use Odyn’s colours, fonts, light and dark themes and a few of its components on your pages.
- Add to Odyn’s sidebar and dashboard. A button in the sidebar with a number on it, a card of updates on the dashboard, and a full-window page (kit version 5).
04 / What extensions can’t do
Extensions run sealed off from the rest of your Mac and from Odyn itself. They can’t:
- Read or write files, or use Node.js.
- Reach the internet on their own. Commands can only reach the websites listed in
allowedHosts, over https, throughodyn.fetch. Pages can’t reach the internet at all. - Open new windows, pop-ups or other web pages.
- See Odyn’s code, settings, keys or other extensions’ data.
- Do anything the user didn’t allow. Every call is checked against the permissions granted at install.
- Keep running in the background. An extension stops after 5 minutes of not being used.
Only for Odyn’s own extensions
A few abilities are kept for extensions Odyn makes, because they reach further into your Mac:
- Running programs on the Mac (the
programs:runpermission and a background helper). - Showing live phone or simulator screens in the sidebar.
- Asking before the agent runs certain programs.
- Giving the agent a written guide (a
SKILL.md). Your agent tools still work without one.
Not shown yet
The kit lets a command be placed in a chat (chatCard) or in settings (settings), but Odyn doesn’t show those yet. A sidebarPanel command runs when someone clicks your sidebar button or dashboard card.
05 / Permissions
Ask only for what you use. People see the list before they install and can untick any of them, so your extension should cope when a call is refused.
- chats:read
- See chat titles and their messages, and which chat is open.
- projects:read
- See project names.
- agents:start
- Send a prompt to a chat, which starts the agent there.
- network
- Reach the websites in
allowedHosts(and their subdomains) over https. - composer:attach
- Put a picture onto the message the user is writing.
- screenshots:read
- Receive a recent screenshot when the user hands one to your page.
- agent:tools
- Give the agent your agent tools. Needs an
agentsection. - programs:run
- Run Mac programs. Odyn’s own extensions only.
- ui:sidebar
- Add a button to Odyn’s sidebar, with a number on it.
- ui:dialog
- Open a full-window page after a click on your button or card.
- ui:dashboard
- Show a card of updates on the dashboard.
- ui:page
- Show your own page in Odyn’s main area when someone clicks your sidebar button.
- schedule:run
- Run your
scheduledcommands on timers you set, even when nothing of yours is open. - spryte:message
- Send messages to Spryte, Odyn’s coordinating agent, which acts on them.
- ui:shortcut
- Run your commands from the keyboard shortcuts in
shortcuts.
06 / Limits
- Command time
- 30 seconds, then the command is stopped.
- Web request
- 15 seconds, up to 1 MB back. Redirects are refused.
- What a command returns
- Up to 256 KB.
- Chat messages
- Up to the last 200 per call.
- Prompt to the agent
- Up to 20,000 characters.
- Toast
- Up to 500 characters.
- Idle
- The extension stops after 5 minutes unused and starts again when needed.
- Page storage
- Up to 5 MB per saved value.
- Attached picture
- Up to 10 MB.
- Page height
- 120 to 600 pixels. 260 if you don’t say.
- Timers
- Up to 50 at once. Repeating ones every 5 minutes to 30 days; one-off ones up to 400 days ahead. Up to 10,000 characters of data each.
- Messages to Spryte
- Up to 20 an hour, 20,000 characters each.
07 / The manifest
The odyn section of your package.json. Odyn checks it when you install from a folder, and the repo checks it on every pull request.
- id
- Lowercase words joined by dashes, e.g. standup-notes. Must match the folder name.
- title
- The name people see.
- description
- One sentence on what it does.
- icon
- Your icon in the Marketplace and on its page: puzzle, pencil, image, note, inbox, cursor, palette, globe, phone or list. Leave it out, or use a name this version of Odyn doesn’t know, and the puzzle piece shows instead. Page and sidebar button icons still take only puzzle, pencil, image or note.
- author
- Your name.
- owners
- GitHub usernames of the people who look after it. They review every change.
- version
- Raise it to release a change.
- apiVersion
- The kit version you built against, 1 to 5. See below.
- permissions
- A list from Permissions above. Can be empty.
- allowedHosts
- Websites
odyn.fetchmay reach. Needed withnetwork. - commands
- Your commands. Can be empty if you have a page, or (kit version 4) an agent section.
- surfaces
- Your pages. See Pages below.
- sidebarItem
- A button in the sidebar (kit version 5). See below.
- dashboardCard
- A card on the dashboard (kit version 5). See below.
- preferences
- Settings fields:
name,title,type(text, password or checkbox) and optionalrequired. - platforms
- Limit it to darwin, linux or win32. Leave out to run everywhere.
- screenshots
- Up to 5 pictures for your Marketplace page, as paths inside your folder, e.g.
screenshots/1.png. Each a .png, .jpg, .jpeg or .webp of at most 1 MB, with forward slashes and no.., leading/, drive or web address. Leave out to show a placeholder with your icon. - agent
summary: one sentence telling the agent what your tools are for. Needed withagent:tools.
Kit versions
- 1
- Commands, permissions and settings.
- 2
- Pages above the message box.
- 3
- Settings pages, platforms and the agent section.
- 4
- Agent tools with inputs, and extensions that are only tools for the agent.
- 5
- A sidebar button, a dashboard card and full-window pages.
08 / Commands and views
Each command in the manifest has a name, a title and a placement. In your code, defineExtension maps each name to an async function. The function receives:
- odyn
- The way to ask Odyn for things. See below.
- input
- What the agent filled in, or what was typed into a form.
- actionId, itemId
- Which button was pressed, and on which list item.
It returns text, nothing, or a view: a list, some markdown, or a form.
// A list. Each item can carry buttons ("actions").return { type: 'list', items: [{ id: 'a', title: 'First', subtitle: 'More detail', actions: [{ id: 'open', title: 'Open' }] }], emptyText: 'Nothing here yet.',}// Markdown text, with optional buttons underneath.return { type: 'detail', markdown: '# Done', actions: [{ id: 'undo', title: 'Undo' }] }// A form. What the user types comes back in `input`.return { type: 'form', fields: [{ name: 'note', title: 'Note', type: 'textarea' }], submitTitle: 'Save',}If the function throws, its error message is passed back instead of a result.
09 / Tools for the agent
Place a command as agentTool, give it a description and, optionally, inputs. While the extension is installed with agent:tools allowed, Claude, Codex and Cursor can call it in any chat. Whatever text the function returns is what the agent reads.
{ "odyn": { // …id, title and the rest, as above "apiVersion": 4, "permissions": ["agent:tools", "network"], "allowedHosts": ["api.open-meteo.com"], "agent": { "summary": "Looks up the weather." }, "commands": [ { "name": "weather", "title": "Weather", "placement": "agentTool", "description": "Gets today's weather for a place.", "inputs": [ { "name": "latitude", "type": "number", "description": "Latitude of the place", "required": true }, { "name": "longitude", "type": "number", "description": "Longitude of the place", "required": true } ] } ] }}defineExtension({ commands: { weather: async ({ odyn, input }) => { const url = `https://api.open-meteo.com/v1/forecast?latitude=${input?.latitude}&longitude=${input?.longitude}¤t=temperature_2m` const response = await odyn.fetch(url) return response.text // Whatever text you return, the agent reads. }, },})- inputs[].name
- Letters, numbers and underscores, starting with a letter.
- inputs[].type
- string, number or boolean.
- inputs[].description
- What the agent is told to put there.
- inputs[].required
- true if the agent must fill it in.
10 / Asking Odyn for things
Commands get an odyn object. Each call checks its permission first.
- chats.list(projectId?)
- Every chat, or one project’s. Needs chats:read.
- chats.messages(chatId, limit?)
- A chat’s latest messages. Needs chats:read.
- chats.current()
- The chat the user is looking at, or null. Needs chats:read.
- projects.list()
- Every project’s id and name. Needs projects:read.
- agents.start(chatId, prompt)
- Sends a prompt to a chat. Needs agents:start.
- fetch(url, init?)
- A web request to an allowed host. Returns status and text. Needs network.
- showToast(message)
- Shows a short message in Odyn.
- preferences()
- The values saved in the extension’s settings.
- ui.openDialog()
- Opens your full-window page. Only during a click on your button or card. Needs ui:dialog.
- ui.setBadge(count)
- The number on your sidebar button. null or 0 clears it. Needs ui:sidebar.
- dashboard.setCard(card)
- Replaces your dashboard card at any time. null hides it. Needs ui:dashboard.
- schedule.set(id, timer)
- Sets or replaces a timer that runs a scheduled command. Needs schedule:run.
- schedule.clear(id) / schedule.list()
- Removes a timer, or lists yours. Needs schedule:run.
- spryte.send(message)
- Sends Spryte a message it acts on. Needs spryte:message.
11 / Pages
A page is your own HTML page, shown in a sealed box inside Odyn. List it under surfaces. Placement composer opens it above the message box from a toolbar button; placement settings (kit version 3) draws it in the extension’s settings page; placement dialog (kit version 5) opens it full-window; placements page and pageSide (kit version 7) fill the main area and the right-hand panel. You can have one of each.
{ "odyn": { // …id, title and the rest, as above "apiVersion": 2, "permissions": ["composer:attach"], "commands": [], "surfaces": [ { "id": "canvas", "title": "Sketch", "placement": "composer", "entry": "dist/panel/index.html", "icon": "pencil", "height": 300 } ] }}- entry
- The page inside your folder, e.g. dist/panel/index.html.
- icon
- The toolbar button: puzzle, pencil, image or note.
- height
- 120 to 600 pixels.
A page talks to Odyn only through connectSurface():
import { connectSurface } from '@odyn-app/api'import { Button } from '@odyn-app/api/ui'const odyn = connectSurface()odyn.onContext(({ chatId, theme, placement }) => { // Load this chat's saved state; match Odyn's light or dark look; // show a compact view when placement is 'pageSide'.})await odyn.chatStorage.set(chatId, 'draft', JSON.stringify(state))await odyn.composer.attachImage({ name: 'sketch.png', mimeType: 'image/png', bytes })odyn.composer.close()- onContext(listener)
- The open chat, the theme and where the page is drawn (placement), now and whenever they change.
- chatStorage.get / set
- A small store for each chat, deleted with the chat or when the extension is uninstalled.
- composer.attachImage(image)
- Puts a picture on the message. Needs composer:attach.
- composer.close()
- Closes the page.
- onImage(listener)
- A screenshot the user hands over. Needs screenshots:read.
Looking like Odyn
Add @import "@odyn-app/api/theme.css"; to your stylesheet for Odyn’s colours, fonts and light and dark themes. @odyn-app/api/ui gives you Button, Skeleton, SettingRow, Orb and cn. Your code may only use its own folder, installed packages and the kit; the build stops if it reaches anywhere else.
13 / Timers and Spryte
From kit version 8, an extension can do things on its own time. Give it a command with placement scheduled and the schedule:run permission, then set timers with odyn.schedule.set. A timer runs once at a date and time, every everyMinutes, or every day at dailyAt (“09:00”), on the Mac’s own clock. The command gets schedule with the timer’s id, its data and when it was due. Timers are kept when Odyn restarts.
A timer that comes due while Odyn is closed or the Mac is asleep is skipped, not run late: a one-off timer is dropped and a repeating one waits for its next time.
With spryte:message, odyn.spryte.send puts a message in Spryte’s chat, labelled with your extension’s name, and Spryte starts on it. If Spryte is busy, the message waits until it finishes.
{ "odyn": { // …id, title and the rest, as above "apiVersion": 8, "permissions": ["schedule:run", "spryte:message"], "commands": [ { "name": "prompt-due", "title": "Prompt due", "placement": "scheduled" } ] }}defineExtension({ commands: { // Runs when a timer set below comes due. 'prompt-due': async ({ odyn, schedule }) => { const prompt = JSON.parse(schedule?.data ?? '{}') await odyn.spryte.send(`Today's prompt is due: ${prompt.text}`) }, // Anywhere else, e.g. when someone saves a prompt: 'save-prompt': async ({ odyn, input }) => { await odyn.schedule.set('prompt-42', { command: 'prompt-due', at: '2026-12-01T09:00:00', data: JSON.stringify({ text: input?.text }), }) }, },})14 / Keyboard shortcuts
From kit version 9, list up to five shortcuts and ask for the ui:shortcut permission. Each has keys, a command from your commands and a title shown in Settings → Keyboard shortcuts. Write keys like mod+shift+u: mod is ⌘ on a Mac and Ctrl elsewhere, then optionally ctrl, alt and shift, then one letter, number, arrow, F-key or punctuation key. Every shortcut needs mod or ctrl. The command is told the open chat through caller when there is one, and may open your dialog.
Odyn’s own shortcuts always win. A shortcut that clashes with one of them, or with an extension installed earlier, does nothing, and Settings shows it as off and says what it clashes with.
15 / Publishing
- Run
bun run publishin your extension’s folder. The first time, it opens GitHub so you can sign in, then opens a pull request on the odyn-extensions repo. - The checks build every extension and check every manifest. Fix anything they flag.
- Once it’s reviewed and merged, your extension is packed, released, and appears in Odyn’s Marketplace. Nothing is downloaded until someone installs it.
- To release a change, raise
versionin yourpackage.jsonand publish again. Installed copies update the next time Odyn starts. - Only an extension’s owners and Odyn can approve changes to it. Anyone else can suggest one, but it isn’t merged until an owner agrees.
Working on a published extension
Get just that one extension’s code, without the rest of the repo:
bunx --package @odyn-app/api odyn-extension pull standup-notesQuestions or ideas? Open an issue on the repo.
16 / Feedback from users
People can send feedback (a bug, an idea or a question, with an optional screenshot) and give a thumbs up from your extension’s page in Odyn.
- It arrives anonymously as an issue on the repo, labelled
ext:<your id>plus the kind, and mentions yourowners. - Answer by commenting on the issue. The sender sees your replies inside Odyn under “Your feedback”, and closing the issue shows it to them as closed.
- The sender is anonymous, so never ask for personal details in a reply.