SDK for building Nebo apps. Mirrors native browser APIs (fetch, WebSocket, localStorage) while adding agent invocation, LLM completions, and real-time state management.
pnpm add @neboai/app-sdkOr, for a page without a build step, load the copy Nebo serves to every app page:
<script src="/sdk/nebo.global.js"></script>
<script>
const { nebo } = window.NeboAppSDK; // the one global; there is no bare `nebo`
</script>An unbundled page cannot import a bare package name; use the served global.
import { nebo } from '@neboai/app-sdk';
// Fetch from your sidecar (relative) or external APIs (absolute, CORS-free)
const data = await nebo.fetch('/deals').then(r => r.json());
const weather = await nebo.fetch('https://api.weather.gov/points/40,-74').then(r => r.json());
// Persistent key-value storage
await nebo.storage.setItem('lastSync', Date.now());
const val = await nebo.storage.getItem('lastSync');
// Invoke the agent
const { text } = await nebo.agents.invoke('Summarize my deals');
// Stream agent responses
for await (const chunk of nebo.agents.stream('Analyze this quarter')) {
console.log(chunk.text);
}
// Direct LLM call (no persona)
const answer = await nebo.janus.complete({
messages: [{ role: 'user', content: 'What is 2+2?' }],
});
// Embed chat panel
nebo.chat.mount(document.getElementById('chat'), {
placeholder: 'Ask about your deals...',
theme: 'dark',
});
// Redraw when the app's employee (or another open window) changes the data
nebo.storage.onChange(() => render());Auto-routed fetch. Relative URLs go to your sidecar. Absolute URLs go through Nebo's CORS-free proxy.
Auto-reconnecting WebSocket to the app's own channel, with exponential backoff (1 s up to 30 s). It takes no path: any argument is ignored. To reach your own server (a game server, for example), open a plain new WebSocket('wss://...') from the page.
Server-persisted async key-value store for your app. It is the same store your app's employee reads and changes, so a record the owner adds by talking to the employee shows on the page, and one typed into the page is one the employee can find.
| Call | Behavior |
|---|---|
getItem(key) |
The value setItem stored (object, list, number, string), or null if the key is absent. |
setItem(key, value) |
Stores any JSON value. |
removeItem(key) |
Removes the key. It no longer appears in keys(). |
keys() |
Every key, in sorted order. |
clear() |
Removes every key. Each removal is reported to onChange. |
onChange(handler) |
Calls handler({ appId, keys, action, source }) after every write to the store. Returns a function that stops listening. |
action is "set" or "delete". source is "employee" when the app's employee made the change and "page" when a page did. Your own page's writes are reported too, so the simplest handler just reloads what it shows:
async function render() {
const contacts = (await nebo.storage.getItem('contacts')) ?? [];
list.replaceChildren(...contacts.map((c) => row(c)));
}
render();
const stop = nebo.storage.onChange((change) => {
if (change.keys.includes('contacts')) render();
});
// later: stop();Tips:
- Pick keys both the page and the employee can find: one key holding a list (
contacts), or one key per record under a prefix (contact:42). The employee can search inside a list item by item. - A string that is itself valid JSON, such as
"42"or"true", comes back parsed (42,true). If the exact type matters, store it inside an object:{ "code": "42" }. - Keep each value well under 2 MB. For large or relational data, use a sidecar.
setItemandremoveItemdo not throw when the server refuses a write. If a save matters, read it back.
invoke(message, options?): one-shot agent call, returns{ text, tools? }stream(message, options?): streaming agent call, yields{ text, done }
complete(options): Direct LLM completionstream(options): Streaming LLM completion
Typed decisions through the bot, no text generated: one call answers every
named question about state (text or any JSON) with probabilities and a
confidence. Billed to the bot owner's NeboAI account, like nebo.janus. The
app's employee asks the same way with its decide tool.
const { answers } = await nebo.decide({
state: { company: 'Example Co', status: 'asked for a quote today' },
questions: {
tier: { type: 'choice', instructions: 'How warm is this lead, by `status`?',
criteria: { hot: 'ready to buy', warm: 'interested', cold: 'not now', other: "can't tell" } },
fit: { type: 'score', instructions: 'How well does `company` fit?', criteria: ['poor', 'fair', 'good'] },
reply: { type: 'noul', instructions: '`status` asks us for a reply.' },
},
});
// answers.tier → { type: 'choice', choice: 'hot', confidence: 0.91, probabilities: {...} }
// answers.fit → { type: 'score', score: 1.6, confidence: 0.8, probabilities: {...} } (0.0 = first level)
// answers.reply → { type: 'noul', noul: 0.97, probabilities: {...} } (probability it holds)choice: 2–255 named options; add an escape option when the set is not exhaustive.score: 2–10 ordered levels, lowest first.noul: one statement; no criteria.- The whole question lives in
instructions; the key only names the answer. - Resolves to
{ model, answers, usage }; each answer comes back under its question's name.noulanswers carry no separateconfidence. - Keep
stateto the fields the questions need and name them in backticks insideinstructions. Very long state is shortened in the middle before it is sent. - Throws an
Errorwhose message is the reason. The error carries only the message, not a status code. The reasons, with the status the bot's route answers:- 400: a malformed question (a choice with one option, a
noulwithcriteria, emptyinstructions), with what is wrong. - 429: "You've used all the work included in your account. Choose a plan or add credits to continue." Retrying does not help until the owner adds a plan or credits.
- 429: "Too many decisions at once. Try again in a moment." The bot has already retried once.
- 503: "Decisions need NeboAI connected. Sign in to NeboAI and try again."
- 502: the decision service failed; try again later.
- 400: a malformed question (a choice with one option, a
- Keep counting, dates and thresholds in your own code; ask only what needs judgment.
- Billed like any model call; see pricing at https://neboai.com/pricing.
- Types:
DecideQuestion,DecideRequest,DecideAnswer,Decision.
Hand the owner a file to share. The file goes into his Work folder, beside
the chat the app is open on, and Nebo opens its own Share dialog on it. He
chooses who can open the link (anyone with it, a password, or only him) and
when it ends. The link is neboai.com/s/<token>; an HTML file opens there as
a live page. The page never makes or changes a link itself.
const { artifact } = await nebo.share({ name: 'Launch deck.html', content: html });
// artifact → '/api/v1/files/Design Studio/Launch deck.html'contentis the file's text (HTML, Markdown, CSV, SVG …), up to the bot's upload limit.- Sharing the same
nameagain replaces the file in Work. - The Share dialog opens only on the device the page is open on (the screen
that opened it, named by the page's
?client=), never on his other devices. - Throws an
Errorwhose message is the reason (400: a bad name or empty content).
mount(element, options?): Embed chat UIunmount(): Remove chat UIsend(message): Programmatically send a messageonMessage(handler): Listen for chat eventssetContext(context): Update app context for the agentnewThread(): Start a new conversation
The app's live channel. Call connect() before using nebo.a2ui: it is the socket that carries interactive cards from the employee to the page.
Today Nebo sends app pages interactive cards (see
nebo.a2ui) and storage changes (seestorage.onChange). The typed events listed below are defined in the SDK, but Nebo does not send them to app pages yet, and nothing answerssend()orrequestState(). Build live updates onstorage.onChangeand cards onnebo.a2ui.
The API:
connect()/disconnect()on(type, handler): Subscribe to events (returns unsubscribe fn)send(name, payload?): Send action to agentrequestState(): Request full state snapshot
Events: run_started, run_finished, run_error, text_start, text_content, text_end, tool_call_start, tool_call_end, state_snapshot, state_delta, surface_create, surface_update, surface_delete, data_update, custom.
get(): Returns agent metadata (id, name, skills, model, etc.)invalidate(): Clear cached identity
A2UI v0.9 message bridge for agent-driven UI components. The app's employee can show a card on your page (a contact, a form, a set of buttons); a click goes back to that same employee.
init(processor): Initialize with an@a2ui/web_coreMessageProcessor. The SDK does not include a renderer; bundle one with your page.sendAction(surfaceId, action): Send UI action to agentsendError(surfaceId, code, message): Report error to agent
Call nebo.surfaces.connect() so cards arrive. Cards reach only the app they were made for. A page opened after a card was sent does not receive it, so keep anything the page must always show in nebo.storage.
Set appId and baseUrl manually (auto-detected by default).
Some app features come from the app's manifest.json and standard browser APIs rather than from the SDK:
| Want | How |
|---|---|
| Full screen, landscape | "window": { "fullscreen": true, "orientation": "landscape" } (orientation: portrait default, landscape, any). Pad with env(safe-area-inset-*) and viewport-fit=cover. |
| Tilt (gyroscope, accelerometer) | Add "device:motion" to permissions, then listen for devicemotion / deviceorientation. On iPhone call DeviceMotionEvent.requestPermission() from a tap. |
| Video and sound | Ship the files in ui/. They are served with their real content types and answer range requests, so <video muted playsinline> plays inline on the phone and seeking works. |
| Fast reloads | Build with content-hashed names (main-0a8ksftt.js): those are cached for a year, every other file is checked on each open. |
Each page file may be at most 10 MB, and a published app at most 50 MB. See the app docs at https://neboai.com/docs/apps.
| Format | File | Use |
|---|---|---|
| ESM | dist/index.js |
import { nebo } from '@neboai/app-sdk' |
| CJS | dist/index.cjs |
const { nebo } = require('@neboai/app-sdk') |
| IIFE | dist/nebo.global.js |
<script> tag, exposes window.NeboAppSDK (Nebo serves it at /sdk/nebo.global.js) |
MIT