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.

  1. 1Your app (widget, SDK or raw REST) sends a message with a workspace-scoped credential.
  2. 2The edge resolves the tenant, enforces the IP allowlist and rate limit, and rejects anything not scoped to that workspace.
  3. 3Hybrid retrieval (pgvector + keyword) pulls the top passages for that workspace only.
  4. 4The model streams tokens back over SSE while tool calls execute server-side behind idempotency keys.
  5. 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/widget and @apppilot/node, published publicly at 1.0.0 with 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.

npm — install the published packages
# 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
script tag — no build step
<!-- 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>
React 19 / Next.js — @apppilot/react
// 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 />
React 19 / Next.js — script tag only, no install
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>
theming & white-label — colors, typography, launcher position
<!-- 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.
hosted ESM — no install, works today
// 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>
server side — @apppilot/node
// 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,
});
server side — plain fetch + node:crypto, no dependencies
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. 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. 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. 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. 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.

React 19 / Vite — src/components/AppPilotWidget.tsx
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;
}
React 19 / Vite — render once in your app shell
import { AppPilotWidget } from "./components/AppPilotWidget";

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <AppPilotWidget />
    </>
  );
}
Plain HTML — paste once before </body>
<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.

AppPilotWidget.tsx
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.

terminal
curl https://api.apppilot.ai/v1/workspaces \
  -H "Authorization: Bearer sk_live_9f21c0..."

SDKs

install
// 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.

Download example repo (.zip)

A minimal Next.js 15 app that installs @apppilot/react and renders a working embedded widget. Four files, no other setup.

nextjs/1-install.sh
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
nextjs/app/apppilot-provider.tsx
"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>
  );
}
nextjs/app/layout.tsx
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>
  );
}
nextjs/app/page.tsx
"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.

stream.ts
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

POST

/v1/conversations

Create a conversation and stream the first reply.

GET

/v1/conversations/:id

Fetch a conversation with its full message history.

POST

/v1/messages

Append a user message to an existing conversation.

GET

/v1/workspaces

List workspaces available to the current API key.

POST

/v1/knowledge/sources

Register a document, sitemap or API source to index.

DELETE

/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. 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. 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. 3

    Verify it works

    Enable debug mode; the panel shows identity: verified plus the resolved subject.

server.ts
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. 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. 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. 3

    Make your webhook handler idempotent

    Store the delivery id you have processed and ignore repeats.

terminal
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. 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. 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. 3

    Or mount it in React

    Load the script in an effect on mount and call identify once your session resolves.

  4. 4

    React to events

    conversation.started, message.sent, conversation.resolved, escalation.created and error are all emitted to .on() handlers.

widget.html
<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. 1

    Clone your config

    New workspace

    Create a Staging workspace, install the same agent from the marketplace and upload a sample of the knowledge.

  2. 2

    Run the API console

    Admin portal → API console

    Fire real requests with your key and inspect the raw response before writing code.

  3. 3

    Check webhook health

    Admin portal → Webhook health

    Confirm deliveries succeed and latency is sane, then replay any failures.

  4. 4

    Rotate before launch

    Admin portal → API keys

    Revoke development keys and issue fresh production keys stored in your secret manager.

Errors

401

unauthorized

Missing or revoked API key.

403

workspace_forbidden

Key is not scoped to that workspace.

422

invalid_request

Schema validation failed; see `details`.

429

rate_limited

Back off using the `Retry-After` header.

503

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.

Run the installation diagnostics

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".

plain HTML — paste before </body>
<script
  src="https://apppilot.tech/widget.js"
  data-slug="your-workspace-slug"
  data-label="Ask AI"
  defer
></script>
React / Next.js — no install required
"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;
}
verify the loader is reachable
curl -I https://apppilot.tech/widget.js
# expect: HTTP/2 200 and content-type: application/javascript

Troubleshooting & 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?

Frequently asked