Developer portal
Build with the AppPilot API
Everything is REST, streams over SSE, and returns predictable JSON errors. Base URL: https://api.apppilot.ai
How it works
AppPilot is multi-tenant by construction. An organization is your tenant; a workspace is one deployed copilot with its own knowledge base, branding, keys, webhooks and analytics. Every credential is scoped to a single workspace and every query is filtered by organization at the database layer, so cross-tenant reads are impossible.
- 1Your app (widget, SDK or raw REST) sends a message with a workspace-scoped credential.
- 2The edge resolves the tenant, enforces the IP allowlist and rate limit, and rejects anything not scoped to that workspace.
- 3Hybrid retrieval (pgvector + keyword) pulls the top passages for that workspace only.
- 4The model streams tokens back over SSE while tool calls execute server-side behind idempotency keys.
- 5The final message, its citations, token cost and latency are persisted and emitted as webhooks.
Install the SDK
Two ways to install — pick one, the API is identical
- 1. npm packages —
@apppilot/react,@apppilot/widgetand@apppilot/node, published publicly at1.0.0with types and ESM + CJS builds. - 2. Script tag — one line of HTML, zero build tooling. Best for React, Next.js, WordPress, Webflow, Shopify or any server-rendered site.
- 3. Hosted ESM module — import straight from
https://apppilot.tech/sdk/v1/…: no install, no registry auth, always available. The/v1/path is version-pinned — breaking changes ship as/v2/.
Current release and artifact URLs are always readable at GET https://apppilot.tech/api/public/v1/sdk-version.
# React 18/19 apps (Vite, Next.js, Remix)
npm install @apppilot/react@1.0.0
# Framework-free browser SDK (widget loader + REST client)
npm install @apppilot/widget@1.0.0
# Node/server: signed end-user sessions + webhook verification
npm install @apppilot/node@1.0.0<!-- Paste before </body>. Replace the slug with your workspace slug. -->
<script
src="https://apppilot.tech/widget.js"
data-slug="your-workspace-slug"
data-label="Ask AI"
data-debug="false"
defer
></script>
<!-- Control it once loaded -->
<script>
window.addEventListener("apppilot:ready", () => {
AppPilot.on("message", (e) => console.log("user asked:", e.detail));
document.querySelector("#help")?.addEventListener("click", () => AppPilot.open());
});
</script>// npm install @apppilot/react
import { AppPilotProvider, useAppPilot, useAppPilotEvent } from "@apppilot/react";
export default function App() {
return (
<AppPilotProvider slug="your-workspace-slug" label="Ask AI" position="right">
<HelpButton />
</AppPilotProvider>
);
}
function HelpButton() {
const { open, ready, identify } = useAppPilot();
useAppPilotEvent("conversation-submit", (detail) => console.log(detail));
return <button onClick={open} disabled={!ready}>Need help?</button>;
}
// No provider? Mount it directly (it takes its own slug):
// import { AppPilotWidget } from "@apppilot/react";
// <AppPilotWidget slug="your-workspace-slug" unmountOnCleanup />import { useEffect } from "react";
export function AppPilotWidget({ slug = "your-workspace-slug", label = "Ask AI" }) {
useEffect(() => {
const s = document.createElement("script");
s.src = "https://apppilot.tech/widget.js";
s.async = true;
s.dataset.slug = slug;
s.dataset.label = label;
document.body.appendChild(s);
return () => {
s.remove();
window.AppPilot?.destroy?.();
};
}, [slug, label]);
return null;
}
// Anywhere in the app:
// <button onClick={() => window.AppPilot?.open()}>Need help?</button><!-- Static branding: every style is a data-* attribute -->
<script
src="https://apppilot.tech/widget.js"
data-slug="your-workspace-slug"
data-label="Ask Acme"
data-accent="#0f766e"
data-text-color="#ecfdf5"
data-font="'Inter', system-ui, sans-serif"
data-radius="12px"
data-position="left"
data-offset="28"
defer
></script>
// Runtime restyle (dark mode, per-tenant brands) — no reload:
AppPilot.theme({ accent: "#22d3ee", textColor: "#082f49", radius: "999px", position: "right", offset: 76 });
// React: theme props are reactive, and useAppPilot() exposes theme()
<AppPilotProvider slug="your-workspace-slug" accent="#0f766e" font="'Inter', sans-serif" radius="12px" position="left" offset={24}>
// Defaults: accent #6366f1 · textColor #ffffff · font system-ui · radius 999px · label "Ask AI" · position right · offset 20
// Workspace-wide defaults (logo, name, colors, greeting) live in Admin portal → Branding.// Browser / any bundler that allows remote imports
import { loadAppPilot } from "https://apppilot.tech/sdk/v1/widget.mjs";
const pilot = await loadAppPilot({ slug: "your-workspace-slug", label: "Ask AI" });
pilot.open();
// React bindings over the same module
// (keeps "react" external, so use it from a bundler such as Vite/Next)
import { AppPilotProvider } from "https://apppilot.tech/sdk/v1/react.mjs";
// Plain HTML, no bundler at all:
// <script type="module">
// import { loadAppPilot } from "https://apppilot.tech/sdk/v1/widget.mjs";
// await loadAppPilot({ slug: "your-workspace-slug" });
// </script>// npm install @apppilot/node (Node 18+, server only — never ship your keys to the browser)
import { AppPilotClient, signUserToken, verifyWebhookSignature } from "@apppilot/node";
// 1. Typed REST client
const client = new AppPilotClient({ apiKey: process.env.APPPILOT_SECRET_KEY });
const { results } = await client.search("refund policy", { limit: 5 });
// 2. Signed end-user session handed to the widget
const jwt = await signUserToken(process.env.APPPILOT_SIGNING_SECRET, {
userId: user.id,
email: user.email,
name: user.name,
});
// 3. Webhook verification (constant-time, rejects stale timestamps)
const ok = await verifyWebhookSignature({
payload: await request.text(),
signature: request.headers.get("x-apppilot-signature"),
timestamp: request.headers.get("x-apppilot-timestamp") ?? undefined,
secret: process.env.APPPILOT_WEBHOOK_SECRET,
});import { createHmac, timingSafeEqual } from "node:crypto";
// 1. REST call with your secret key
const res = await fetch("https://apppilot.tech/api/public/v1/search", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.APPPILOT_SECRET_KEY}`,
},
body: JSON.stringify({ query: "refund policy", limit: 5 }),
});
if (!res.ok) throw new Error(`AppPilot ${res.status}: ${await res.text()}`);
const { results } = await res.json();
// 2. Signed end-user session for the widget (never sign in the browser)
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
const header = b64({ alg: "HS256", typ: "JWT" });
const payload = b64({
sub: user.id,
email: user.email,
name: user.name,
exp: Math.floor(Date.now() / 1000) + 3600,
});
const signature = createHmac("sha256", process.env.APPPILOT_WIDGET_SECRET)
.update(`${header}.${payload}`)
.digest("base64url");
const token = `${header}.${payload}.${signature}`;
// pass it to the browser: <script ... data-user-token="${token}">
// 3. Webhook verification — on the RAW body, before JSON.parse
const expected = createHmac("sha256", process.env.APPPILOT_WEBHOOK_SECRET)
.update(`${req.headers["x-apppilot-timestamp"]}.${rawBody}`)
.digest("hex");
const got = req.headers["x-apppilot-signature"];
const ok =
got?.length === expected.length &&
timingSafeEqual(Buffer.from(got), Buffer.from(expected));Where do I find my workspace slug?
Slugs are private to each tenant, so they are never listed in these public docs. Sign in to the admin dashboard — it is on Overview → “Your workspace slug” (copy slug / copy script tag buttons), and again under White-label → Install the widget and Guides & handbook. If you are not an admin, ask your organization admin to send it — that single value is all a developer needs. Format: 3–64 letters, digits, dashes or underscores, case-sensitive.
- 1
Copy your workspace slug
In the admin dashboard, the slug is shown on Overview → Your workspace slug (and under White-label → Install the widget). Use the copy button — never retype it, it is case-sensitive.
- 2
Add the hosted loader
Use one of the snippets below and replace the your-workspace-slug placeholder with the slug you copied. Leaving the placeholder in produces the “This preview link is not available” panel.
- 3
Start your local application
Run your application normally (for example npm run dev). The hosted loader works on localhost and injects the launcher after the page loads.
- 4
Verify the installation
Reload the page and look for the launcher in the bottom corner. If it is missing, enable data-debug and run Widget Doctor with the same workspace slug.
import { useEffect } from "react";
declare global {
interface Window {
AppPilot?: {
open(): void;
close(): void;
destroy?(): void;
on(event: string, handler: (detail: unknown) => void): () => void;
};
}
}
export function AppPilotWidget() {
useEffect(() => {
if (document.querySelector("script[data-apppilot-widget]")) return;
const script = document.createElement("script");
script.src = "https://apppilot.tech/widget.js";
script.dataset.apppilotWidget = "true";
script.dataset.slug = "your-workspace-slug";
script.dataset.label = "Ask AI";
script.dataset.debug = import.meta.env.DEV ? "true" : "false";
script.defer = true;
document.body.appendChild(script);
return () => {
window.AppPilot?.destroy?.();
script.remove();
};
}, []);
return null;
}import { AppPilotWidget } from "./components/AppPilotWidget";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<AppPilotWidget />
</>
);
}<script
src="https://apppilot.tech/widget.js"
data-slug="your-workspace-slug"
data-label="Ask AI"
data-debug="true"
defer
></script>Quick start
No npm package is required. Create a workspace, copy its slug from the admin portal, and load the hosted widget script. The copilot is live as soon as the first knowledge source finishes indexing.
import { useEffect } from "react";
export function AppPilotWidget() {
useEffect(() => {
const script = document.createElement("script");
script.src = "https://apppilot.tech/widget.js";
script.dataset.slug = "your-workspace-slug";
script.dataset.label = "Ask AI";
script.defer = true;
document.body.appendChild(script);
return () => script.remove();
}, []);
return null;
}Authentication
Server calls use a secret key in the Authorization header. Browser widgets use a publishable workspace key that can only start conversations.
curl https://api.apppilot.ai/v1/workspaces \
-H "Authorization: Bearer sk_live_9f21c0..."SDKs
// No install. Import the hosted ESM module directly.
import { AppPilotProvider } from "https://apppilot.tech/sdk/v1/react.mjs";Quickstart bundles
Copy-pastable starting points for every surface, plus a downloadable example repo containing all of them.
A minimal Next.js 15 app that installs @apppilot/react and renders a working embedded widget. Four files, no other setup.
npx create-next-app@latest my-app --ts --app --no-src-dir
cd my-app
npm install @apppilot/react
# @apppilot/widget is pulled in automatically as a dependency.
echo 'NEXT_PUBLIC_APPPILOT_SLUG=your-workspace-slug' >> .env.local
npm run dev # open http://localhost:3000"use client";
// The provider mounts the widget in the browser, so it must be a client component.
import { AppPilotProvider } from "@apppilot/react";
export function AppPilot({ children }: { children: React.ReactNode }) {
return (
<AppPilotProvider
slug={process.env.NEXT_PUBLIC_APPPILOT_SLUG!}
host="https://apppilot.tech"
label="Ask AI"
position="right"
accent="#6366f1"
>
{children}
</AppPilotProvider>
);
}import { AppPilot } from "./apppilot-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* Everything inside can call useAppPilot(); the launcher renders itself. */}
<AppPilot>{children}</AppPilot>
</body>
</html>
);
}"use client";
import { useAppPilot, useAppPilotEvent } from "@apppilot/react";
export default function Home() {
const { open, ready } = useAppPilot();
useAppPilotEvent("conversation-submit", (detail) => console.log("asked:", detail));
return (
<main style={{ padding: 48 }}>
<h1>My app</h1>
<button onClick={open} disabled={!ready}>
{ready ? "Need help?" : "Loading assistant…"}
</button>
</main>
);
}Streaming
Responses stream as server-sent events. Tokens arrive as delta frames and end with done.
const res = await fetch("https://api.apppilot.ai/v1/conversations", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ workspace_id, message, stream: true }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(decoder.decode(value, { stream: true }));
}REST endpoints
/v1/conversations
Create a conversation and stream the first reply.
/v1/conversations/:id
Fetch a conversation with its full message history.
/v1/messages
Append a user message to an existing conversation.
/v1/workspaces
List workspaces available to the current API key.
/v1/knowledge/sources
Register a document, sitemap or API source to index.
/v1/api-keys/:id
Revoke an API key immediately.
Webhooks
Every delivery is signed with HMAC-SHA256 in the X-AppPilot-Signature header. Verify before you trust the payload.
conversation.started
A user opened a new conversation.
conversation.resolved
The copilot marked the intent as resolved.
message.flagged
Moderation flagged a message for review.
action.requested
The copilot wants to call one of your APIs.
usage.threshold
Workspace crossed a configured usage threshold.
Integration guides
Detailed walkthroughs for the parts developers get wrong most often: end-user identity, safe retries, embedding in your own app, and promoting from staging to production.
End-user identity (signed JWT)
Let the copilot answer account-specific questions safely.
- Your backend signs a short-lived HS256 JWT with the workspace signing secret; the browser never sees the secret.
- The widget passes it to the embed endpoint, which verifies signature and expiry before binding the conversation to that user.
- An invalid or expired token downgrades the session to anonymous instead of failing the widget.
- 1
Mint the token server-side
Claims: sub (your user id), email, name, exp (5–15 minutes), workspace_id. Never mint it in the browser.
- 2
Pass it to the widget
Call AppPilot.identify(token) right after your app knows who the user is, and again after a token refresh.
- 3
Verify it works
Enable debug mode; the panel shows identity: verified plus the resolved subject.
import jwt from "jsonwebtoken";
const token = jwt.sign(
{ sub: user.id, email: user.email, name: user.name, workspace_id: WORKSPACE_ID },
process.env.APPPILOT_SIGNING_SECRET!,
{ algorithm: "HS256", expiresIn: "10m" },
);
// return token to the browser, then: AppPilot.identify(token)Idempotency, retries & rate limits
Safe retries for anything that writes.
- Send Idempotency-Key on every POST that creates something. The first response is stored and replayed for 24h, so a retry never duplicates work.
- Rate limits are per key and per workspace; a 429 includes Retry-After — back off with jitter.
- Webhook deliveries retry with exponential backoff and can be safely replayed from the admin portal because handlers see the same idempotency key.
- 1
Generate a stable key
Use a UUID derived from your own record id, not a random value per attempt — otherwise the retry looks like a new request.
- 2
Treat 429 and 503 as retryable
Everything else in the 4xx range is a bug in the request; fix it rather than retrying.
- 3
Make your webhook handler idempotent
Store the delivery id you have processed and ignore repeats.
curl -X POST https://api.apppilot.ai/v1/conversations \
-H "Authorization: Bearer $APPPILOT_SECRET" \
-H "Idempotency-Key: conv_8f21c0a4" \
-H "Content-Type: application/json" \
-d '{"workspace_id":"ws_123","message":"How do I rotate a key?"}'Embedding in your own app
Script tag, React and Next.js — plus events and debugging.
- The loader creates an isolated iframe, so nothing leaks between your styles and the widget.
- AppPilot.open(), .close(), .identify(token) and .on(event, handler) are available as soon as the script loads.
- data-debug="true" renders a panel with config, CORS, identity and a live event log.
- 1
Get the workspace slug
Ask your organization admin for the workspace slug, or copy it yourself from the dashboard Overview → Your workspace slug. Slugs are tenant-private, so they are never published in the developer docs.
- 2
Drop in the script
Add the snippet from White-label → Install before </body>, with data-slug set to that exact value (case-sensitive).
- 3
Or mount it in React
Load the script in an effect on mount and call identify once your session resolves.
- 4
React to events
conversation.started, message.sent, conversation.resolved, escalation.created and error are all emitted to .on() handlers.
<script
src="https://cdn.apppilot.ai/widget.js"
data-workspace-key="pk_live_..."
data-debug="true"
defer
></script>
<script>
window.addEventListener("apppilot:ready", () => {
AppPilot.identify(sessionToken);
AppPilot.on("conversation.resolved", (e) => console.log("resolved", e.conversationId));
});
</script>Environments & going live
Separate test from production without duplicating work.
- Use one workspace per environment (Staging, Production) so keys, knowledge and analytics never mix.
- Test keys are rate-limited lower and are excluded from billing dashboards.
- Custom domains and white-label presets are per workspace, so staging can look identical to production.
- 1
Clone your config
New workspaceCreate a Staging workspace, install the same agent from the marketplace and upload a sample of the knowledge.
- 2
Run the API console
Admin portal → API consoleFire real requests with your key and inspect the raw response before writing code.
- 3
Check webhook health
Admin portal → Webhook healthConfirm deliveries succeed and latency is sane, then replay any failures.
- 4
Rotate before launch
Admin portal → API keysRevoke development keys and issue fresh production keys stored in your secret manager.
Errors
unauthorized
Missing or revoked API key.
workspace_forbidden
Key is not scoped to that workspace.
invalid_request
Schema validation failed; see `details`.
rate_limited
Back off using the `Retry-After` header.
model_unavailable
Transient upstream failure; retry with jitter.
All errors return { error: { code, message, details } }
Installation troubleshooting (widget.js & npm 404)
The fastest fixes for failed installs, starting with the most common one: trying to npm install a package that does not exist.
The SDK is published on npm.
@apppilot/widget, @apppilot/react and @apppilot/node are live at 1.0.0. You can also skip the registry entirely and use the hosted ESM modules at https://apppilot.tech/sdk/v1/ or the one-line script tag.
Panel opens but says "This preview link is not available"
Why: The data-slug on the script tag is not a real workspace slug — usually the docs placeholder "your-workspace-slug" was left in place. Your real slug is shown (with a copy button) in the admin dashboard on Overview → Your workspace slug, and again under White-label → Install the widget. It is never listed in these public docs.
Fix: Copy the exact slug from Admin portal → Branding → Install the widget (or the Embed wizard), paste it into data-slug, and hard-reload. Add data-debug="true" or run /widget-doctor to confirm the embed URL resolves.
npm error 404 Not Found — GET https://registry.npmjs.org/@apppilot%2freact
Why: A stale npm cache or a private/proxy registry that has not mirrored @apppilot yet. The packages are published publicly as @apppilot/widget, @apppilot/react and @apppilot/node.
Fix: Run npm cache clean --force, then npm install @apppilot/react --registry https://registry.npmjs.org/. If you use a corporate proxy registry, allow-list the @apppilot scope or use the hosted ESM module instead.
widget.js loads but no launcher appears
Why: Missing or wrong data-slug, or the script was injected before <body> existed.
Fix: Copy the workspace slug from Branding in the admin portal and inject the script in a useEffect / after DOMContentLoaded, appending to document.body.
404 or CORS error fetching widget.js
Why: The script src points at a local path or the wrong host.
Fix: Always use the absolute URL https://apppilot.tech/widget.js. It is served with permissive CORS for any origin.
Widget renders twice in React 18/19 dev mode
Why: StrictMode double-invokes effects.
Fix: Return a cleanup function from useEffect that calls script.remove() and window.AppPilot?.destroy?.().
Vite/Next build error: Cannot find module '@apppilot/react'
Why: The dependency was never installed, or node_modules is out of sync with package.json.
Fix: Install it: npm install @apppilot/react@1.0.0 (react and react-dom are peer deps). Alternatively import the hosted module: import { AppPilotProvider } from "https://apppilot.tech/sdk/v1/react.mjs".
<script
src="https://apppilot.tech/widget.js"
data-slug="your-workspace-slug"
data-label="Ask AI"
defer
></script>"use client";
import { useEffect } from "react";
export function AppPilotWidget({ slug }: { slug: string }) {
useEffect(() => {
if (document.querySelector("script[data-apppilot]")) return;
const script = document.createElement("script");
script.src = "https://apppilot.tech/widget.js";
script.dataset.apppilot = "true";
script.dataset.slug = slug;
script.dataset.label = "Ask AI";
script.defer = true;
document.body.appendChild(script);
return () => {
script.remove();
(window as any).AppPilot?.destroy?.();
};
}, [slug]);
return null;
}curl -I https://apppilot.tech/widget.js
# expect: HTTP/2 200 and content-type: application/javascriptTroubleshooting & FAQ
Answer a couple of questions and we point at the exact fix for the most common setup failures.
Widget doesn’t appear
The launcher never shows up on your site.
Open the browser console — do you see an [AppPilot] error?
Add ?apppilot_debug=1 to the URL to open the diagnostics panel.
SSO sign-in fails
Users bounce back to the login page or see “Unsupported provider”.
Which protocol did you configure?
IP allowlist is blocking requests
API or portal calls return 403 “network not allowed”.
Who is blocked?
Signed JWT identity is rejected
The widget shows as anonymous or logs an identity error.
Was the token signed with the current widget secret?
Billing actions are denied
Upgrade, seat change or invoice download returns a permission error.
Is your membership role org owner?