The app
Design record of the Python line (
nanomuse/+web/, tagpre-openminis): this is the web appnanomuse serveserves, today the front door of the desktop app, and of a runtime opened in a browser tab (fomo.corein.in/web itself is the demo on a simulated phone). The phone app inandroid/is OpenMinis-based and described in android.md; its first run starts with the account sign-in, then the model. The face everywhere is the bundled dragon, or one drawn for you in the avatar studio. The red panda and the dolls of earlier versions are gone (a profile that still names one wears the dragon).
nanomuse serve runs an always-on agent and serves the phone app from the same process. The app is a React single-page app built into nanomuse/server/static/ and shipped inside the Python package; the server is FastAPI with a WebSocket for live events.
nanomuse serve # http://127.0.0.1:8787, this machine only
nanomuse serve --host 0.0.0.0 # also reachable from your phone on the same network
nanomuse serve --port 9000 --no-qr



The look is monochrome: near-white and near-black surfaces, blue for links only, a light grey pill for the agent's messages and, for yours, a pill in the chat's own ink (white on the black canvas, black on the white one, see android.md), a small dragon at the top of the chat that changes pose with what the agent is doing, and a floating frosted-glass pill with five icons. The font is Figtree (OFL), bundled. The study behind these choices is in design.md.
Getting it onto your phone
- Start with
--host 0.0.0.0(or setserver.host). The terminal prints a URL and a QR code. - Scan the code. The link carries the access token in its fragment (
#token=…, which the browser never sends to any server); the app stores it and drops it from the address bar. - In the browser menu choose Add to Home Screen. The app has a manifest and icons, so it opens full-screen like a native app.
The token is generated once and stored in <data_dir>/server_token; set server.token or NANOMUSE_SERVER_TOKEN to choose your own. Keep server.auth = true on any network you do not fully control. To reach the app from outside your network, put it behind something you trust (Tailscale, a reverse proxy with TLS) rather than opening the port.
First run

On a fresh data directory the app opens with setup instead of the chat. First three points about what it is (it does things for you; it keeps working when the app is closed; it asks you first where it matters) and a line saying there is no form to fill in: once it has a model, it introduces itself in the chat and asks what to call you. Then a checklist, ticked as it is done:
- Add a model: providers grouped by protocol (OpenAI-compatible Chat Completions · Responses API · local or your own endpoint) with vendor subtitles: DeepSeek, Kimi, Qwen, GLM, Doubao, MiniMax, OpenAI, OpenRouter, Ollama, or any OpenAI-compatible endpoint. The key is masked with a reveal toggle and each vendor has a Get a key link; it goes into the vault on the server and the model never sees it. A Base URL without a path gains
/v1; Ollama and custom endpoints may have no key. The endpoint's own/modelsfills the list (a catalogue stands in when it cannot be reached); a model you typed is never replaced. - Connect mail, calendar, contacts: optional.
- Start: opens the chat, where the agent speaks first, asks your name and picks its own with you, the first conversation (web.md).
Start stays locked until a model is saved; Skip setup is always there and skips the first conversation too. A reload keeps the ticks. The name, face, tagline and tone can be changed later under the avatar (Settings has the identity form). Setup does not reappear once finished, or once a conversation exists. Start marks setup done (POST /api/firstrun/start, the same as POST /api/onboarded); when a conversation already exists at that point, or when the first conversation ends, the Feed's first day is written in the background, so the room is not empty the first time it opens; the Feed otherwise opens on its intro card and says when its daily routine runs.
What is on the screen
Five tabs (Chat, Feed, Ideas, Goals, Library) and a menu behind the avatar. At phone width the five sit in a rounded pill that floats 12 px above the bottom edge and is as wide as the chat's composer (420 px at most, centred on a wider screen), icons only and no labels, the same bar the Android app draws: the pill is translucent over a blur of what is behind it and has a hairline edge; the current tab shows its filled glyph on an ink indicator, black in the light theme and white in the dark one, the others stay outlined in grey. The screens and the chat's composer keep clear of it. On a wide window, and in the desktop app at any width, a sidebar takes the bar's place.
Chat. One main conversation plus side chats (the round Chats button top left opens the drawer that lists them). Messages stream in as they are generated. Tool calls appear as chips: tap one for the arguments and output. Files the agent writes show up as artifact cards that open in the app. When Sentinel needs a decision an approval card appears; when the agent needs information a question card appears. The composer stays open while the agent works; anything you send is folded into the running turn before the next model call.
Attachments. The + button (or a paste) attaches photos and files, up to ten per message, 25 MB each (server.max_upload_mb), from the camera, the photo library or the phone's files. They upload as you pick them, show as thumbnails and chips you can remove, and a message may be attachments alone. Each file lands in the workspace under attachments/<date>/ (so it is in the Library and any tool can use it) and the message lists them for the agent with what they are. Pictures go to the model as images when it takes them: llm.vision = auto sends them and, if the endpoint refuses (DeepSeek and most text-only models do), sends the text only from then on and tells you once (the picture stays as a file, the agent knows its name and says it cannot see it rather than guessing); off never sends pictures, on insists. Phone photos are scaled to 1568 px before they are encoded, so a 10 MB picture costs what a small one does. PDFs, spreadsheets and documents the agent reads with files; PDF text is extracted page by page (a scanned PDF says it has no text layer). In the chat, tap a picture or a file chip to open it.
Browser view. When the agent uses the browser tool, a browser card appears with what it is looking at: a picture of the page after every action, the page title, where it is, and what it just did ("Opened example.com", "Clicked 'Sign in'"). The card is LIVE while the run goes on and stays in the chat afterwards. Tap it for the full view; Take over puts you at the controls (tap the picture to click there, type into the focused field, press Enter, open a URL) and Hand back returns the page to the agent, which is told what you did and continues from there. That is how a login happens: the agent stops at the form and asks, you sign in, it carries on. Passwords you type go to the website, never through the model. Frames are kept in memory on the server for the current session only (the last dozen per chat); after a restart old cards show a placeholder. Needs the browser tool (pip install "nanomuse[browser]" && playwright install chromium, or the -browser Docker image), or the Android app, whose own WebView is the agent's browser whenever the app is connected (the card says which: backend). In the app, Take over on such a card slides the real page up as a sheet rather than a picture of it; sign in with the phone's keyboard and autofill, tap Done, and the agent carries on. The whole story, including what cannot log in inside a WebView: browser.md.
Feed. Two things. At the top, Feed instructions: tell it what you want to read about ("keep me up to date on cycling and Rust, one recipe a week"), and once a day, or when you tap New posts, the agent writes three short posts for you from those instructions, your goals and what it remembers. Each post has Ask {name}, which starts a conversation about it. Below that, While you were away: one entry per background pass (its final word, and any file it made), every approval or question still waiting for you in any chat, today's calendar, and a Next up card that says when the next pass runs and which goal is in line. Unseen entries are counted on the tab.
If posts, activity, Next up or the calendar cannot load, that section shows the error and Try again. A retry fetches only that section. A failed refresh keeps the last successful content visible alongside the error.
Ideas. Suggestions generated from your goals, memory and recent conversation, grouped by area: planning, research, goals, money, health, home, learning, people, files, fun. Tap one to send it as a message; Refresh regenerates.
Goals. Two lists. Tracking is what gets checked on a schedule (a goal with a check-in cadence); Goals is the rest, done step by step. Each row has a check circle that fills as the plan progresses, one line of status (next check-in, last update, due date) and the count of steps done. Goals are created by the agent or by you (the + button), filed under an area of life (health, finance, career, learning, relationships, family, home, travel, creative) with an optional target date and an optional check-in cadence. Each goal has a plan; steps are pending, in progress, done or blocked, with notes. Work on it now runs one background pass on that goal and posts the result to the main chat; Check in with me now sends the reminder message right away.
- Target date. Cards say "Due in 5 days" / "Was due Sep 12"; overdue goals go first when the background pass picks what to work on, and the agent is told about them.
- Check-ins. "Every day at 08:00", "Weekdays at 07:30", "Mondays at 09:00", "Monthly on the 1st": at that time the agent sends one short message (what the goal is about, what the next small step is, how is it going) and does no work. Check-ins arrive at any proactivity level (you asked for them) but wait out quiet hours. The Upcoming view lists the next ones.
- Plan changes. When the agent learns the plan no longer fits, it does not edit it; it proposes a revised set of remaining steps with a reason. The proposal shows as a card at the top of Goals and inside the goal: Use corein's plan keeps the finished steps and swaps the rest, Keep my plan leaves everything as it is. Either way a note lands on the goal.
Reminders and routines. Say it in chat ("remind me at six to call mum", "every weekday at 07:30 give me a one-line weather check") or add one under Upcoming. A reminder is one short message at the time you named, in the chat you set it from, and nothing else; a routine is a task the agent does at that time with its tools (read the inbox, check a page, run a script) and then reports on. Cadences are the check-in grammar: daily 08:00, weekdays 07:30, weekly mon 09:00, monthly 1 09:00. A time you named is kept whatever the proactivity level or the quiet hours; if the chat is busy the message queues behind the conversation rather than being skipped. Fired items stay listed for a week under finished recently. The same list is available from the terminal: nanomuse reminders list | add | cancel.
Triggers: when something happens. The other half of reminders: work that starts from the world instead of the clock. Say it in chat ("when the landlord writes back, summarise it and draft a reply", "half an hour before any meeting with review in the title, put together a one-page brief", "when my deploy script calls you, check that the site is up") or add one under Upcoming → When something happens. Four kinds: new mail (a message whose sender or subject contains every word you named arrives; the inbox is looked at every five minutes while such a trigger exists, and connecting a mailbox never replays old mail), before an event (a calendar event whose title or place matches is N minutes from starting), and webhook (a URL with a key; anything that can make an HTTP request (a CI job, a home-automation rule, a cron line with curl) POSTs to it and the body becomes the context), and device (a device of the account comes online, its name or kind containing every word you named; this computer never announces itself, and the devices already online when the app started are not an arrival). Each time it fires the agent does the work in the chat the trigger was set from, with the mail, the event, the arrival or the request in front of it, and the result lands in the Feed as New mail: …, Coming up: …, Webhook: … or Device online: …. The same thing never fires twice (a mail's UID, an event's start), a webhook refuses deliveries closer together than ten seconds, and a wrong key looks exactly like a wrong URL. Mail, events and who is around are your private data: from then on the conversation is tainted, so sending anything to a host that is not allowlisted needs an approval, and the model is told to treat what arrived as data, never as instructions. Run now fires one with a sample occurrence to see what it does; the list shows how often each fired and when the inbox was last read. From the terminal: nanomuse triggers list | add | cancel.
Library. Every file in the agent's workspace, newest first, filtered by kind (pages, documents, images, data, code) and searchable. Files open in the app: pages render live, Markdown is formatted, CSV becomes a table, images and PDFs display inline. A page the agent wrote runs in a sandboxed frame with an opaque origin (it cannot read the access token or call the API) and the server sends Content-Security-Policy: sandbox with every HTML file for the same reason.
Avatar. The dragon sits at the top of the chat with a status line under it: what the agent is doing right now in this chat, or that it is waiting for you. It has a still for each state (web/public/avatars/dragon-*.webp): idle; typing at a small laptop with headphones on while it works; gazing into a crystal ball while it waits for you; hugging a star when a run finishes; a sweat drop when a tool call fails or is refused. Tap it and it is pleased about it. A face drawn in the avatar studio has the same five stills; Settings offers an emoji instead, which moves as a whole (breathe, sway, hop). On the Android app a short clip of your own can play as the face instead, kept on that phone only (avatar.md). All of it stops under prefers-reduced-motion. The chat tab carries a badge with the approvals waiting anywhere. Tap the avatar for the status sheet: the avatar large, the same status line, a Stop button while something runs (the run ends, pending cards in that chat close, and the conversation stays usable), the model and the Sentinel mode, then:
- Approvals: the queue of cards waiting for you across all chats, answerable right there. Opens first when something is pending.
- Activity: the audit trail, every tool call, decision and approval, including refused ones.
- Permissions: the Sentinel mode, and every standing permission you granted with a revoke button on each.
- Upcoming: the background-work switch, the next pass time, the goals in line with a run now button, the next check-ins, your reminders and routines, and your triggers (When something happens), each with + Add, run now and cancel; a webhook has a copy button for its URL.
- Memory: everything the agent has remembered about you, by category, plus an entry box. Forget deletes an item; the agent will not see it again. Each turn, the memories that fit the message go into the agent's prompt: by keyword, and (with an embedding endpoint set up under Connections → Recall by meaning) by meaning too, so brings up "the landlord is Bob Li" though they share no word. Tidy up runs one pass of the housekeeping the agent also does on its own (after every eight new lines, or weekly, at any proactivity level but Off): lines that say the same thing are merged into one, a fact that changed keeps the newer version, and one-off requests that were never facts about you are dropped. The model proposes; corein checks that a merged line adds no words that were not there, refuses to drop anything you wrote yourself, and takes out at most a fifth of the store per pass. Recent changes lists every merge, drop and update with the text it replaced, each with Undo. A tidy-up that changed something is one entry in the Feed.
- Skills: how a job is done, written down once (a weekly review, a trip plan, an inbox triage). See Skills below.
- Connections: what the agent can reach, plugged in and out from the phone. Model: provider presets (Alibaba Cloud Bailian, OpenRouter, DeepSeek, OpenAI, Ollama, any OpenAI-compatible endpoint), model name, tool-calling mode, and the API key. What each provider covers and the ChatGPT sign-in are in web.md; the key is written to the vault as
LLM_API_KEYand swapped in for every thread on the spot; Test asks the model for a one-word reply. With a corein Cloud account the chat model isdeepseek-v4.1-flash. Hands model, Making pictures, Making clips: the other three rows of the Models page the phones and the desktop have under Settings → Models, here as cards under the chat model; each opens with Automatic (the chat model's provider when it can do the job, else the account's model when signed in) and says Currently provider · model. The hands send the screenshots of the browser and the computer to a model that sees images:qwen3.8-27bon the Cloud; of DeepSeek's, only ids withv4.1,visionorocr. Recall by meaning: whether memories are also recalled by what they mean, through an OpenAI-compatible/embeddingsendpoint: Auto (the model's endpoint when it has one, keyword recall when it does not), On, Off; the model's own endpoint or another one (Ollama withqwen3-embedding:0.6bnext to DeepSeek, which has none; the key goes to the vault asEMBEDDINGS_API_KEY); the embedding model, blank for the endpoint's default. Test makes one embeddings call and, when it works, indexes every memory on the spot; the card's line says how many are indexed. Web search: who answersweb_search: DuckDuckGo (nothing to set up, but scraped and rate-limited now and then), Brave Search, Tavily or Serply with a key (vault:SEARCH_API_KEY, with a link to where keys come from), You.com with or without a key (100 searches a day without one), or a SearXNG instance by URL; Test runs one search and shows the provider, the count and the time. Whatever is picked, a failed search falls back to DuckDuckGo with a note. Email: presets for common providers, address and app password (vault:EMAIL_ADDRESS,EMAIL_PASSWORD), IMAP/SMTP servers; Connect saves and signs in to both servers to prove it works; Disconnect removes the credentials and the tools. Calendar: add any private.icslink (the screen says where Google, Outlook, iCloud and Fastmail hide theirs) or a file path; the link goes to the vault asCALENDAR_<NAME>, the feed is read on the spot and its event count shown; working hours for free time; Read again re-fetches every feed. Today's events appear in the Feed under Today (tomorrow's once today is over), and the agent sees them in its prompt. An event the agent drafts opens as a card with Add to calendar. Contacts: upload a.vcfexport from the phone (Google Contacts, iCloud, Outlook, the phone's own contacts app; the screen says where each hides the export), or give a path or a link (the link goes to the vault asCONTACTS_<NAME>); each address book shows how many people it has; My contacts is the book the agent fills from chat; a search field looks people up exactly as the agent does. Browser: on/off, with the install hint when Playwright is missing. MCP servers: add a server by command (stdio) or URL, choose the risk level of its tools, remove it again; servers fromconfig.tomlare listed read-only. Vault: the names of every stored secret, add or delete one. Only names ever leave the server. - Settings: the agent's name, avatar, colour and personality, and what it calls you; the Sentinel mode (Balanced =
ask, Cautious =strict, Hands-off =auto) and whether commands run in the sandbox; proactivity (the Off / Low / Default / High dial, the check-in interval, quiet hours; see Background work); notifications (below); on the Android app, Keep it running: the battery, overlay and alarm permissions, start after a reboot, log export (archive/android-python-line.md); show thinking; app language; reply language; and two version lines that are always there: the installed version and the newest release (Latest 0.1.x; you have it / 0.1.x is out · Update / Could not check · Check now;fomo.corein.in/dl/index.jsonfirst, GitHub second).
The app speaks English and Simplified Chinese. Settings → App language is a device setting (stored in the browser, not on the server): Auto follows the browser's language, otherwise pick one; dates and relative times follow it too. It is separate from Reply language, which is what the agent writes in. Strings live in web/src/i18n/: the English text is the key, zh-CN.ts the translation, and a unit test fails when a string in the app has no translation.
Skills
A skill is a recipe: a folder with a SKILL.md (a name and a one-line description up top, then the steps in Markdown) in the Agent Skills format, so a skill written for another agent works here and yours work there. Eleven ship with the app (weekly-review, trip-plan, inbox-triage, compare-options, meeting-prep, phone-messages, and the service skills feishu, tencent-meeting, amap, kuaidi100, train-tickets); yours live in <data_dir>/skills/<name>/, and one with the same name as a built-in replaces it.
The model sees the index (every enabled skill's name and description) in its system prompt and picks one when a request fits ("plan me a week in Kyoto" reaches for trip-plan), reading the full steps with the skills tool before it starts. You can also name one yourself: type / in the composer and the enabled skills come up (Tab completes the first match); /trip-plan Kyoto, 5 days in November sends the skill's instructions with your text as the task.
Three ways to get a new one. Write it in Skills → New (a template appears once the name is set), paste a link (a raw SKILL.md, or a GitHub folder or file page) and Fetch, or, after a job went well, tell the agent "save this as a skill": it writes down what it did as steps and asks you first (skills action=save is a sensitive call, so it needs your approval even in auto mode, and so does removing one). Switch a skill off to keep it out of the model's list; Make your own copy on a built-in opens it in the editor under your name. A skill's own scripts and reference files are visible read-only inside the sandbox. From the terminal: nanomuse skills list | show | add | new | remove | enable | disable.
Notifications
Settings → Notifications → Let corein notify this device. Standard Web Push through the browser's own push service, no account with anyone: the server generates a VAPID key pair once (<data_dir>/push-vapid.json) and keeps the subscriptions of your devices (push-subscriptions.json). You get a notification when the agent needs your approval, asks a question, finished a background pass that had something to report, or it is check-in time on a goal. Quiet passes and step-by-step narration never leave the app, and nothing is shown while the app is on screen: the card is already there. Tapping a notification opens the right chat. On a phone with the app on the home screen, the icon carries a badge with the number of cards waiting for you.
Push needs a secure context: https:// or localhost. Over plain http:// on your LAN the rest of the app works and the toggle explains why this part is off; see deployment for a TLS setup. Embedded browsers (the kind inside another app) usually have no push service at all; use Chrome, Edge, Firefox or Safari 16.4+ (iOS: home-screen apps only).
Non-secret choices made in Connections are stored in <data_dir>/app-settings.json and layered over config.toml on every start, for the CLI too, so a phone-only setup never needs a file edited. Secrets are only ever referenced from there as {{vault:NAME}}.
Opening the app on something
The page takes a few query parameters, which the Android app uses and a bookmark can too: ?thread=<id> opens a conversation, ?tab=goals (feed, ideas, library, connections) a tab; &draft=<text> puts text in the composer without sending it; &attach=<json> (the JSON list of {path, name, mime, size} that POST /api/files/upload returned) adds files already in the workspace as attachment chips. They are read once and removed from the address bar. This is how Share → corein works on the phone: the app uploads the shared files (POST /api/files/upload), starts a thread (POST /api/threads) named after the subject or the first file, and opens it with the text as a draft and the files attached, waiting for what to do with them. On the phone the agent also has the phone's own capabilities as tools and the workspace appears in the Files app: device.md.
Background work
The agent does things on its own, but not too many. The Proactivity dial in Settings sets how much:
| Level | Passes | Reaches out |
|---|---|---|
| Off | never | — |
| Low | every 2 × interval | only when a step got finished or it needs you |
| Default | every interval | when there is real progress or something you would want to know |
| High | every ½ interval | always, including "still on track" |
On each pass the service picks one active goal with a pending step, runs it in the main chat with the Sentinel in auto mode (explicit deny rules and dangerous-call warnings still apply; those turn into cards in the Feed), and lets the model decide whether the result is worth your attention. A pass with nothing to say begins its summary with [quiet]: the chat shows one muted line ("Checked on goal: nothing new", tap to expand), the Feed lists it as a one-liner, and nothing is badged. Anything else is a normal message from your corein.
Quiet hours ("22:00–08:00", server local time) hold background work; a pass that would fall inside the window runs when it ends. The Upcoming view and the Feed's Next up card show the level, the effective interval and the next pass time, or the end of the current quiet window.
Turn the dial to Off for goals that need your judgement at every step, or leave those goals paused.
API
Everything the app does goes through this API, so another front-end (a Telegram bot, a desktop widget) can drive the same agent. All requests need Authorization: Bearer <token> unless server.auth = false. (?token= is refused since 0.1.33: a token in a URL lands in every access log on the way; a request that still sends one gets a 401 that says so, a socket an error frame with code: "legacy_token" and a 4401 close.) The bytes the app shows inline (/api/files/*, the browser frames) also open with a link the client signed with its token, ?exp=<unix seconds>&sig=<HMAC-SHA256(token, "<exp>\n<path>")[:32]>: a logged link opens that one path for a few hours and nothing else.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health | liveness, version |
| GET | /api/state | profile, status, threads, pending approvals, goals, settings |
| GET / POST | /api/threads | list threads / create one {title} |
| PATCH / DELETE | /api/threads/{id} | rename / delete |
| POST | /api/threads/{id}/clear | clear the conversation |
| GET | /api/threads/{id}/events?limit=&before= | timeline events |
| POST | /api/threads/{id}/send {text, files?, language?, via?} | queue a message; returns immediately. files: workspace paths from the upload below, ten at most; text may be empty when there are files. language (since 1.0.0): the BCP-47 tag of the client's screens (en, zh-CN); the reply is written in that language unless Reply language fixes one. Without it the script of the message decides, as before. via: "cloud" is Use corein Cloud this time: this one turn runs on the account's recommended chat model under the account's key, the next is back on the configured model, and [llm] does not move; the user bubble, the reply and a failure of that turn carry model_used. 401 signed_out when no account is signed in |
| POST | /api/threads/{id}/stop | stop the run in that chat: the queue is dropped, pending approval and question cards there expire, the conversation stays usable. {ok: false} when nothing was running |
| POST | /api/files/upload?name= (body: the bytes) | a file to attach: lands in attachments/<date>/ under a safe version of name; returns {path, name, size, kind, mime} for files. 413 above server.max_upload_mb |
| POST | /api/approvals/{id} {approved, scope, reason} | answer a card; scope is one of the card's grant_options (once, conversation, session, 24h, always; task is still read as conversation) |
| DELETE | /api/approvals | forget every granted permission |
| DELETE | /api/approvals/grants/{key} | revoke one permission (key as listed by /api/activity, e.g. shell:git) |
| GET / POST | /api/goals[?status=&category=] | list / create {title, description, steps[], category, due (YYYY-MM-DD), check_in ("daily 08:00", "weekdays 07:30", "weekly mon 09:00", "monthly 1 09:00")} |
| GET / PATCH / DELETE | /api/goals/{id} | read / update {status, note, step_index (1-based), step_status, step_note, title, description, category, due, check_in} ("" clears due / check_in) / delete |
| POST | /api/goals/{id}/steps {title} | add a step |
| POST | /api/goals/{id}/advance | run one background pass now |
| POST | /api/goals/{id}/check-in | send the reminder message now |
| POST / DELETE | /api/goals/{id}/proposal/accept, /api/goals/{id}/proposal | accept / dismiss the agent's proposed plan change |
| GET / POST | /api/memory · DELETE /api/memory/{id} | list / add {content, category} / forget |
| POST | /api/memory/tidy (?dry_run=1) | one tidy-up pass now; the report lists what was merged and dropped (or would be) |
| GET | /api/memory/changes · POST /api/memory/changes/{id}/restore | the log of merges, drops and updates, newest first; undo one |
| GET | /api/ideas?refresh=1 | cached or regenerated suggestions |
| GET | /api/activity | audit tail, granted permissions (grants[] with key, tool, target, scope, expires_at), taint flag |
| GET | /api/feed?limit= | While you were away: {id, ts, kind (background · artifact · approval · question), title, text, thread, thread_title, path} newest first |
| GET | /api/feed/posts | the posts written for you: {instructions, daily, time, generated_at, posts[{id, ts, title, body, area, prompt}], error?} |
| PUT | /api/feed/instructions {instructions?, daily?, time?} | what the posts should be about (≤ 2000 characters), and the daily routine: on or off, and when (HH:MM, 08:00 at first) |
| POST | /api/feed/posts/refresh | write new posts now; error says why not when the model refused |
| DELETE | /api/feed/posts/{id} | remove one post |
| GET | /api/upcoming | {proactivity, proactive, interval_minutes, effective_interval_minutes, quiet_hours, quiet_until, next_pass_at, next_wake_at, queue[{goal_id, title, category, due, overdue, next_step, progress}], check_ins[{goal_id, title, at, cadence}], reminders[], triggers{items[], available{mail, event, hook, device}, mail_checked_at, mail_error, mail_poll_minutes}, busy}: next_wake_at is the earliest moment anything scheduled is due (a reminder, a check-in, the next pass), for a host that can set an alarm (local-runtime.md) |
| POST | /api/tick | wake the scheduler now: it looks at what is due and runs it; {ok, next_wake_at}. The Android app calls it when its alarm fires |
| GET / POST | /api/reminders[?all=1] | active (or all recent) reminders / create {text, at ("YYYY-MM-DD HH:MM") or repeat ("daily 08:00", …), kind (remind · task), thread} |
| POST / DELETE | /api/reminders/{id}/fire, /api/reminders/{id} | deliver it now in its chat / cancel |
| GET / POST | /api/triggers | triggers with which kinds have their connector / create {kind (mail · event · hook · device), text, match, lead_minutes, thread}; a hook comes back with its url |
| POST / DELETE | /api/triggers/{id}/fire, /api/triggers/{id} | run it now with a sample occurrence / cancel |
| POST | /api/hooks/{id}?key= | a trigger's webhook: no app token, the key is the credential (also accepted as X-Hook-Key); the body (≤ 64 KB, JSON pretty-printed) is the context. 404 for a wrong id or key, 409 once cancelled, 429 when deliveries come too close |
| GET | /api/files · /api/files/{path}?download=1 | list / read workspace files (HTML and SVG are served with Content-Security-Policy: sandbox) |
| GET | /api/data/export | the agent's data as one zip (parity.md note 37): the profile, the rooms, the address book, the approvals, threads/, sessions/ and a README naming every part; never vault.enc, vault.key, server_token, push-vapid.json, the settings file, logs/ or workspace/; a file over 64 MB is skipped; answered as an attachment with Cache-Control: private, no-store |
| GET / PUT | /api/settings | view / change {profile{name (≤ 20), avatar, emoji, color, tagline (≤ 60), tone, communication, style, user_name, proactive, goal_interval_minutes}, sentinel_mode, show_thinking, language}; the view includes onboarded and llm_ready |
| GET | /api/connections | model (key_source: vault · config · missing · none; endpoint when it is one of the list), provider presets, endpoints (the custom OpenAI-compatible endpoints of the list, each {id, label, base_url, key_source, models, slots}, never a key), email, browser, MCP servers (connected, tools, from_app), vault names, onboarded |
| PUT | /api/connections/llm {provider, model, base_url, tool_mode, api_key} or {endpoint, model, tool_mode} | change the model; api_key set → stored in the vault, "" → no key, omitted → unchanged. endpoint names one of the list, which lends the slot its address and its own key by reference. Takes effect immediately in every thread. An address with user:pass@ in it is refused with 400 (a key goes in api_key); the same holds for /api/llm/models, /api/connections/gui, /image, /video and the endpoint list |
| POST | /api/connections/llm/test | one short round trip to the model: {ok, reply, ms} or {ok: false, error} |
| POST | /api/llm/models {preset, base_url, api_key} or {endpoint} | the models an endpoint offers: its /models (then /v1/models) with the given key (or, without one, the key in the vault), returning {models, source: "live"}; when it cannot be reached, the preset's catalogue with source: "catalogue" and error, or, for an endpoint of the list, the models saved with it. Redirects are followed only within the endpoint's own origin while the request carries a key (error: "redirect to another host" otherwise). Saves nothing |
| POST | /api/connections/endpoints {label, base_url, api_key, models} | add a custom OpenAI-compatible endpoint to the list (#294): label optional (the host otherwise), api_key optional (vault: ENDPOINT_<ID>_API_KEY), models the ids it serves, comma-separated, when it does not list them (400 with a sentence when neither is there). Answers the entry; the address of corein Cloud or of a catalogue preset is refused, and an address another endpoint of the list already has is 409 naming that endpoint |
| PUT | /api/connections/endpoints/{id} {label, base_url, api_key, models} | change an endpoint: a new name, address, key ("" removes it) or typed models; omitted fields stay. Every slot that uses the endpoint follows. 404 for an unknown id, 409 for an address another endpoint has |
| DELETE | /api/connections/endpoints/{id} | remove an endpoint and its vault key; hands, pictures or clips set on it go back to automatic. 409 while the chat model answers through it |
| POST | /api/connections/endpoints/{id}/models | ask the endpoint for its models again and save them: the entry plus source: "live", or "kept" with error when it did not answer and the saved list stands |
| PUT | /api/connections/embeddings {mode, model, base_url, api_key} | recall by meaning: mode auto/on/off; base_url "" → the model's endpoint; model "" → the endpoint's default; api_key set → vault (EMBEDDINGS_API_KEY), "" → the model's key, omitted → unchanged. Rebuilds the embedder on the spot |
| POST | /api/connections/embeddings/test | one embeddings call, then every memory indexed: {ok, model, dims, indexed, ms} or {ok: false, error} |
| PUT | /api/connections/search {provider, api_key, base_url} | web search: provider duckduckgo/brave/tavily/serply/you/searxng; api_key set → vault (SEARCH_API_KEY), "" removes it, omitted → unchanged; base_url the SearXNG instance. Applies to the next search |
| POST | /api/connections/search/test | one search against the configured provider, no fallback: {ok, provider, results, first, ms} or {ok: false, error} |
| PUT / DELETE | /api/connections/email | save {address, password, imap_host, imap_port, smtp_host, smtp_port, smtp_starttls, enabled} (address and password go to the vault) / disconnect and forget the credentials |
| POST | /api/connections/email/test | sign in to IMAP and SMTP: {ok, inbox} or {ok: false, error} |
| PUT | /api/connections/calendar {enabled, day_start, day_end, refresh_minutes} | working hours and refresh; 400 for a malformed time |
| POST · DELETE | /api/connections/calendar/feeds {name, url} · /api/connections/calendar/feeds/{name} | add or replace a feed (the link goes to the vault as CALENDAR_<NAME>; read at once; error says why not) / remove one added from the app |
| POST | /api/connections/calendar/test | re-read every feed: {ok, events, feeds} or {ok: false, error} |
| GET | /api/calendar | {configured, today, events[{uid, summary, start, end, all_day, location, description, calendar}], feeds[], fetched_at, stale}: today's and tomorrow's events |
| PUT | /api/connections/contacts {enabled} | turn the contacts tool on or off |
| POST · DELETE | /api/connections/contacts/sources {name, url} · /api/connections/contacts/sources/{name} | add or replace an address book: a .vcf path, or a link kept in the vault as CONTACTS_<NAME> (read at once; error says why not) / remove one added from the app |
| POST | /api/connections/contacts/import?name= | upload a .vcf (the request body is the file), kept under <data_dir>/contacts/ and added as a source |
| POST | /api/connections/contacts/test | re-read every book: {ok, contacts, sources} or {ok: false, error} |
| GET | /api/contacts?q=&limit= | look people up as the agent does: [{id, name, emails[], phones[], org, …}] |
| PUT | /api/connections/browser {enabled} | turn the browser tool on or off |
| PUT | /api/connections/gui {enabled, provider, model, base_url, api_key} | operating the phone (gui.md): the switch adds or removes the phone tools at once; the model fields are the operator's own model ("" → the main model's); api_key set → vault (GUI_API_KEY), "" → the main key, omitted → unchanged. endpoint instead of provider / base_url / api_key names an endpoint of the list, as for the chat model; so do PUT /api/connections/image and /video |
| POST | /api/connections/gui/test | one short round trip to the operator's model: {ok, reply, ms} or {ok: false, error} |
| GET | /api/phone | {connected, device{id, name, platform, gui, apps, width, height}, gui_enabled, last_screen, screen}: which phone is connected and the last screen it sent |
| GET | /api/hands | this computer's own screen and hands (gui.md): {enabled, available, backend, reason, mode, coords, screen_size, picture_size, window{available, reason, active, app, title, frame}, device, task_active, task_text, last_screen}; on a phone {enabled: false, available: false, reason} |
| PUT | /api/connections/hands {enabled, backend (auto · desktop · pyautogui · xdotool), mode (auto · screen · window)} | the switch for this computer's hands and which backend drives them; omitted fields stay; 400 for an unknown backend or mode. Answers the view above |
| POST | /api/hands/stop | Stop on the Hands card: the task on this computer's screen ends at its next step; {stopped} |
| GET / POST | /api/holds · {thread, tool (browser · computer · phone), reason?} | the holds that are on / take over: the agent's actions of that kind in that chat wait until Done. Answers the hold event {type: "hold", id, thread, tool, status, by, reason, ts}; 404 for an unknown thread |
| POST | /api/holds/{id}/done | Done: the hold goes off and the waiting tool looks again and continues; one that is already off comes back as it is. 404 for an unknown id |
| GET / PUT | /api/connections/image · /api/connections/video {provider, model, base_url, api_key} or {endpoint, model} | the picture and clip slots of the avatar studio (avatar.md): provider a catalogue id (or openai / openai_responses with a base_url), model empty for the catalogue's default, api_key set → vault, "" removes it, omitted → unchanged; all four "" clears the slot and pictures (clips) come from the chat provider or the account again |
| GET | /api/providers?lang=®ion= | the catalogue with what each slot uses, what that covers and one sentence per capability nothing covers (lang: en · zh, region: cn · global); what the Add a model sheet is drawn from |
| POST · GET · GET · POST · POST | /api/chatgpt/login · /status · /usage · /callback {url} · /logout | the ChatGPT sign-in of own-key.md: start the Codex sign-in (the page to open), the token store without the tokens, what is left of the plan's usage windows, the callback address pasted from the browser (400 for another login's state), forget the sign-in |
| POST · DELETE | /api/connections/mcp {name, command, args[], env{}, url, risk} · /api/connections/mcp/{name} | connect a server now (502 if it does not come up) / disconnect and remove one added from the app. Each env value goes into the vault as MCP_<SERVER>_<KEY> and the settings file keeps {{vault:...}}; a value typed as a reference is kept as typed; removing the server removes those entries. The view lists each server's env as [{name, vault}], never a value |
| GET | /api/skills | {count, built_in, yours, dir, errors{}, skills[{name, description, source (built-in · yours), enabled, path, files[], metadata{}, channel, updated_at}]} |
| GET / PUT / DELETE | /api/skills/{name} | one skill with body and content (the whole SKILL.md) / write it from {content} (a built-in name creates your copy) / delete one of yours |
| POST | /api/skills/{name}/enabled {enabled} | switch a skill on or off (remembered in app-settings.json) |
| POST | /api/skills/import {url} | fetch a SKILL.md (a raw link, or a GitHub folder or file page) and save it |
| GET · PUT · DELETE | /api/vault · /api/vault/{name} {value} | list secret names / store / delete. Values are never returned |
| POST | /api/onboarded {done} | mark first-run setup as finished; with a model configured the feed's first day is written in the background right then (feed_started) |
| GET | /api/firstrun (?lang=) | the first conversation (web.md): {phase (none · ask_user_name · ask_agent_name · named · done), session_id, user_address, suggestions, chips, chosen, lang, running, started_at, finished_at, intro[]}; chips is what the chooser offers, intro the three opening lines in lang |
| POST | /api/firstrun/start {lang} | Start: binds the conversation to the main chat, marks setup finished, moves none → ask_user_name; with a conversation already there the ritual is skipped (done). Answers the view with intro |
| POST | /api/firstrun/pick {name} | a chip was picked while ask_agent_name: the name is saved to the profile, the phase is named; 409 when no pick is due, 400 for an empty name |
| POST | /api/firstrun/dismiss | the chooser goes and the ritual is over (done) |
| GET | /api/nudges (?refresh=1) | when the app may ask for a star on GitHub: the relay's policy (/v1/nudges, read once a day and on sign-in) over the built-in defaults, as {star{enabled, url, moments{signed_in, tasks[], new_look, exhausted, days_used[], goal_done}, cooldown_days, max_asks, text, text_zh}, version, source}; text / text_zh is the sentence the star card shows when the relay sets one (200 characters at most, else the app's own words) |
| GET | /api/update (?refresh=1) | {current, enabled, latest, newer, url, download_url, checked_at, source (fomo.corein.in · github), error}: the newest release from fomo.corein.in/dl/index.json, then GitHub's releases/latest; a day's cache, an hour for a check that failed |
| GET / PUT | /api/sync/state · {enabled} | conversations synced between the account's devices (every-device.md): {enabled, available, paused, cursor, last_pull_at, last_push_at, error, relay}; available is "signed in and the setting allows it", paused is set after a 401 until the next sign-in, relay is the relay's own {enabled, cursor, counts{conversations, messages}, limits} or null when it could not be asked. PUT {enabled: false} turns it off here and deletes what the relay holds for the account (the other devices' switches follow at their next push or pull, which the relay refuses with sync_off); PUT {enabled: true} turns it on again and pushes the chats here. A relay refusal comes back as its status and code (409 sync_off). Since 0.1.38 the state also carries side_chats (this device's Also sync side chats, off by default: only the main conversation is pushed and pulled) and working ([{thread, cid, device, device_name, at}]: the other devices answering right now, from the hub's working frames, each entry dropped after ten minutes); PUT {side_chats: true} sends this device's side chats up and pulls the account's once from the start, false keeps them local from then on |
| POST | /api/sync/delete | delete every synced conversation on the relay, switch left as it is; returns the state |
| POST | /api/sync/pull | fetch and apply the other devices' changes now: {applied, …state} |
| GET | /api/cloud | the corein Cloud account as the app shows it: {base_url, signed_in, required, models, hint, channel, signed_in_at, has_password, account_id, member, any_model, region, is_model} (cloud.md). Every /api/cloud/* route below answers a relay refusal with the relay's status, its sentence, and the code in the X-Nanomuse-Code header; 401 bad_key when no account is signed in |
| GET | /api/cloud/config | the relay's public figures (the allowance a new account gets, the invite bonus, whether sign-up is open, the repository to star); {} from an older relay or none |
| POST | /api/cloud/code {identifier} | send a sign-in code to an e-mail address or a phone number; {ok} |
| POST | /api/cloud/verify {identifier, code, invite?} | sign in with the code (an invite code on a first sign-up); answers the relay's /v1/me and refreshes the nudges policy |
| POST | /api/cloud/login {identifier, password} | sign in with the account's password; answers as verify does |
| POST | /api/cloud/password {password, current?} | set or change the password (current when one is set, unless the key came from a code sign-in just now; an empty password with current removes it) |
| GET | /api/cloud/me | the account as the relay sees it now (/v1/me), nudges policy refreshed on the way |
| GET · DELETE | /api/cloud/sessions · /api/cloud/sessions/{prefix} | the account's sign-ins on every device / revoke one by the prefix the list shows; both answer {sessions} |
| POST | /api/cloud/sign-out · /api/cloud/sign-out-all {all} | sign this device out / revoke every other device's sign-in (all: true this one too, {signed_out} with the account view) |
| GET | /api/cloud/events?limit= | the account's activity log from the relay: {events}; limit 1 to 200, 50 by default |
| POST | /api/cloud/models {on} | Use corein Cloud models: off, the account's models leave the automatic order of the hands, pictures and clips and the providers' listing; the sign-in stays. 409 chat_on_cloud while the chat model is the account's |
| POST | /api/cloud/use-as-model {model?} | make the account the chat model: the recommended model unless one is named, the Cloud key from the vault; switches the account's models back on. Answers the connections view's llm |
| POST · DELETE | /api/cloud/contribute {on} · /api/cloud/samples | data controls: Help improve corein's AI models on or off (the relay keeps chat turns only while it is on) / delete what it kept, {deleted} |
| POST | /api/cloud/delete | delete the account on the relay and sign out here; answers the account view |
| GET / PUT | /api/hub · {enabled, remote_control, name} | this device on the account's hub (hub.md): {enabled, remote_control, state, detail, device{id, name, kind, actions}, devices[], account} / join or leave, allow or refuse remote control, rename this device (60 characters at most) |
| POST | /api/hub/join · /api/hub/leave · /api/hub/refresh | join (401 when no account is signed in) / leave / ask the relay for the device list again; each answers the hub view |
| DELETE | /api/hub/devices/{device_id} | forget a device of the account; 502 when the hub is not connected |
| POST | /api/hub/ask {device, text?} | the side chat addressed to a device (by id or name; created if needed) and, with text, the first message in it, which runs on that device: {thread, event}; 404 for no such device |
| GET | /api/bridge/tools | for a command the agent runs, not the app: the device tools nanomuse-device list prints, {ok, tools[{name, description}]}. No app token; the call token the shell tool minted comes in X-Nanomuse-Bridge (401 otherwise), as for POST /api/bridge/{kind} (local-runtime.md) |
| GET | /api/browser/{thread}/frames/{id}.jpg | a browser frame (JPEG, in memory for the current session) |
| POST | /api/browser/{thread}/control {action: click(x,y as 0–1 fractions) · type(text) · key(key) · scroll(dy) · navigate(url) · look · handed_back} | you drive the agent's browser; handed_back is what the Android app sends when you close its take-over sheet (a fresh frame, the agent told); 409 when the browser is not open (open a URL first) |
| GET | /api/push | {available, public_key, subscriptions, devices[]}: the VAPID public key to subscribe with |
| POST | /api/push/subscribe {subscription}, /api/push/unsubscribe {endpoint} | register / drop this device's PushSubscription |
| POST | /api/push/test | send a test notification to every subscribed device |
| WS | /ws | live events; the first frame is {"kind": "auth", "token": …} |
WebSocket
The client's first frame is {"kind": "auth", "token": "…"} (within ten seconds, or the socket closes with 4401, as it does for a wrong token). Then the server sends {"kind": "hello", "state": …} (the same payload as /api/state). Then:
| Server → client | Meaning |
|---|---|
event | a new timeline event (user, assistant, tool, approval, question, artifact, browser, notice). An approval carries summary, purpose (what you asked for), target, grant_key, grant_options, risk, warnings, args. A browser card carries url, title, action, frame (id of the latest picture), frames, status (live / done), by_user, backend (playwright or device) and the frame's width / height in CSS pixels. Events produced during a background pass carry source: "background" and about (the pass label). The assistant bubble that ends a run carries final: true (set on emit, or as an update when the bubble was already on screen); the step-by-step narration before it does not; a client that mirrors background results into notifications should key off that flag |
update | fields changed on an existing event (a tool finished, an approval was decided, a browser card got a new frame) |
event_removed | {thread, id}; an event left the timeline: a message deleted on another device of the account (conversation sync) |
working | {thread, cid, device, device_name, working, at}: another device of the account started (true) or finished (false) a turn in a synced conversation; the app shows {device} is working… under the last message while true, and drops it when the reply arrives or after ten minutes. The hello state lists the live ones under working |
stream_start / delta / stream_end | the assistant reply being generated; stream_end carries discard: true when what streamed turned out not to be a reply (a prompt-mode tool call, a quiet background pass) |
status | idle / working / waiting, with a short detail line |
thread, thread_cleared, thread_deleted | thread list changes |
firstrun | {firstrun}; the first conversation moved on (Start, the model's nanomuse-naming block, a pick, a dismissal): the same view as GET /api/firstrun without intro; the chat redraws its chooser. The hello state carries it under firstrun |
goals, memory, ideas, feed_posts, profile, settings, connections, skills, approvals_reset | refresh hints for the tabs |
phone | a phone connected or left: the /api/phone view |
schedule | next_wake_at changed (a reminder was set or fired, a goal's cadence moved, the quiet hours ended): {next_wake_at} or null when nothing is due. The Android runtime re-arms its alarm from it |
device_ack, device_request | to a connected phone: the answer to its announcement, and a request for its screen or an action (gui.md) |
error, pong | replies to client messages |
Client → server: {"kind": "send", "thread": "main", "text": "…", "language": "en", "via": ""} (language and via optional, as on POST /api/threads/{id}/send; a refused via: "cloud" comes back as {"kind": "error", "error": "…", "code": "signed_out"}), {"kind": "approval", "id": "…", "approved": true, "scope": "once"}, {"kind": "ping"}. A phone that lets the agent operate it also sends {"kind": "device", …} once and {"kind": "device_result", …} in answer to each request.
Timeline events are persisted per thread in <data_dir>/threads/<id>.json, so the history survives restarts.
Developing the front-end
cd web && npm install
npm run dev # Vite on http://localhost:5173, proxied to the server on 8787
npm run build # writes nanomuse/server/static/ — commit the resultAdd cors_origins = ["http://localhost:5173"] to [server] while using the dev server. The app is plain React + TypeScript + Tailwind with no state library; web/src/store.tsx holds the reducer and the WebSocket client, web/src/screens/ one file per tab.
