# Build a browser extension that makes flashcards

In this tutorial you build a browser extension that sends the text you select on any page to Anki. Select a sentence and a small export button appears beside it; one click and the card is in your collection with a link back to the page. The toolbar popup does the same with an editable card and a deck list. It uses [WXT](https://wxt.dev) for the extension, [Base UI](https://base-ui.com) for the interface, and the [TypeScript SDK](https://ankinize.com/docs/sdk) for the Ankinize API, so it works with Anki closed and syncs to every device.

## What you will build

Three parts. A **selection widget** is a round export button that appears beside any text you select; clicking it adds a card to your starting deck. A **background script** sends the card to Ankinize. The **popup** opens from the toolbar, reads the selection of the current tab, and shows a form with the front, an optional back, a link switch and a deck list. The **options page** stores your server, your API key and a starting deck. The cards are **Basic** notes, so `fields` has two keys, `front` and `back`.

The finished project, with every file and a `README`, is in the [ankinize-examples](https://github.com/0x0501/ankinize-examples/tree/main/browser-extension) repository. Each step below shows a file from it, unchanged.

- **Bun** and a Chromium browser (or Firefox).
- An Ankinize account with an API key that has the `write` scope, from *API Keys & Devices*. Make a key for this extension alone.
- At least one deck in your collection.

## Build it

1. Create the project
   
   WXT scaffolds the extension. Choose the React template, then add the SDK and Base UI. Base UI ships components with behaviour and accessibility but no styles, which suits an extension: you write the few rules you need and nothing else is bundled. `web-ext` is what lets `bun run dev` open the browser for you; without it WXT only prints where the build is.
   
   Terminal
   
   ```
   bunx wxt@latest init ankinize-clip --template react
   cd ankinize-clip
   bun add @ankinize/sdk @base-ui/react
   bun add -d web-ext
   ```
   
   This tutorial keeps the code under `src/` (the `srcDir` option in the config, step 2). A WXT project finds its pages in `src/entrypoints`: a folder with an `index.html` named `popup` becomes the toolbar popup, and one named `options` the settings page. WXT writes the manifest from them, so you only declare what they cannot say.
2. Declare the permissions
   
   `activeTab` with `scripting` lets the popup read the selection of the tab you are on when you open the popup, and only that tab. The widget is a content script, which the manifest lists from its `matches` in the next steps; it needs no permission of its own. `host_permissions` names the Ankinize server, which lets the popup call it without CORS. The optional permission is for self-hosters: the options page asks for your own server's origin when you save.
   
   wxt.config.ts
   
   ```
   import { defineConfig } from "wxt";
   
   export default defineConfig({
     srcDir: "src",
     modules: ["@wxt-dev/module-react"],
     manifest: {
       name: "Ankinize: selection to flashcard",
       description:
         "Select text on any page and add it to your Anki as a Basic card through Ankinize.",
       // activeTab + scripting read the selection when you open the popup, so the
       // extension needs no access to every page.
       permissions: ["activeTab", "scripting", "storage"],
       host_permissions: ["https://ankinize.com/*"],
       // The options page asks for your own server's origin when you self-host.
       optional_host_permissions: ["https://*/*", "http://*/*"],
     },
   });
   ```
3. Store the settings
   
   WXT's `storage` wraps the extension storage with types and a fallback. Using the `local:` area keeps the key on this device and out of Chrome sync.
   
   src/lib/settings.ts
   
   ```
   import { storage } from "wxt/utils/storage";
   
   export interface Settings {
     /** Where Ankinize runs: https://ankinize.com, or your own server. */
     baseUrl: string;
     /** An API key with the `write` scope. */
     apiKey: string;
     /** The deck the popup starts on. */
     deck: string;
   }
   
   export const DEFAULT_BASE_URL = "https://ankinize.com";
   
   const settings = storage.defineItem<Settings>("local:settings", {
     fallback: { baseUrl: DEFAULT_BASE_URL, apiKey: "", deck: "Default" },
   });
   
   export const getSettings = () => settings.getValue();
   export const saveSettings = (value: Settings) => settings.setValue(value);
   ```
4. Read the selection
   
   When the popup opens, run a one-line function in the active tab and return `window.getSelection()`. A page's own scripts never see the API key, because the key only ever lives in the popup. Pages that cannot be scripted (the browser's own settings pages, the web store) throw, so the popup falls back to an empty selection and you can type the card by hand.
   
   src/lib/selection.ts
   
   ```
   import { browser } from "wxt/browser";
   
   export interface PageSelection {
     text: string;
     title: string;
     url: string;
   }
   
   /**
    * Reads what is selected in the active tab. Opening the popup grants
    * `activeTab` for that tab, which is what lets `executeScript` run there.
    */
   export async function readSelection(): Promise<PageSelection> {
     const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
     const page = { title: tab?.title ?? "", url: tab?.url ?? "" };
     if (tab?.id === undefined) return { text: "", ...page };
   
     try {
       const [result] = await browser.scripting.executeScript({
         target: { tabId: tab.id },
         func: () => window.getSelection()?.toString() ?? "",
       });
       return { text: String(result?.result ?? "").trim(), ...page };
     } catch {
       // Pages such as chrome:// and the web store cannot be scripted.
       return { text: "", ...page };
     }
   }
   ```
5. Turn text into card fields
   
   A note's `fields` are an object keyed by each field's name in camelCase, so Basic's `Front` and `Back` are `front` and `back`. Card fields hold **HTML**, and a selection or a page title can contain markup, so everything is escaped before it goes in, and only `http(s)` addresses become links. Keeping this in its own module makes it easy to test.
   
   src/lib/card.ts
   
   ```
   export interface CardInput {
     front: string;
     back: string;
     /** Add a link to the page the text came from, under the back. */
     source?: { title: string; url: string };
   }
   
   /** Card fields hold HTML, and a selection or a page title can contain markup. */
   export const escapeHtml = (text: string) =>
     text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
   
   /** Plain text to field HTML: escaped, with line breaks kept. */
   export const toHtml = (text: string) =>
     escapeHtml(text.trim()).replace(/\r?\n/g, "<br>");
   
   /** Only web links become links; javascript: and the like stay plain text. */
   function sourceLink({ title, url }: { title: string; url: string }) {
     const label = escapeHtml(title || url);
     return /^https?:/i.test(url)
       ? `<a href="${escapeHtml(url)}">${label}</a>`
       : label;
   }
   
   /** The `fields` of a Basic note: its keys are the field names in camelCase. */
   export function basicFields({ front, back, source }: CardInput) {
     const parts = [toHtml(back)];
     if (source) parts.push(sourceLink(source));
     return {
       front: toHtml(front),
       back: parts.filter(Boolean).join("<br><br>"),
     };
   }
   ```
6. Make the form controls with Base UI
   
   `Field` connects a label, a hint and an error message to its control, and `Field.Control` renders a textarea when you pass one to `render`. `Select` gives a keyboard-driven list with a trigger, a popup and items, and you style them through the class names and `data-*` attributes it sets, such as `data-highlighted`. The stylesheet is in the [repository](https://github.com/0x0501/ankinize-examples/tree/main/browser-extension/src/ui/styles.css).
   
   src/ui/Field.tsx
   
   ```
   import { Field } from "@base-ui/react/field";
   import type { ReactNode } from "react";
   
   interface TextFieldProps {
     label: string;
     hint?: ReactNode;
     /** Render a textarea instead of a single-line input. */
     multiline?: boolean;
     name: string;
     value: string;
     onValueChange: (value: string) => void;
     type?: "text" | "password" | "url";
     required?: boolean;
     placeholder?: string;
   }
   
   /**
    * A labelled text control. Base UI's Field wires the label, the hint and the
    * validation message to the control, so we only style it.
    */
   export function TextField({
     label,
     hint,
     multiline,
     onValueChange,
     ...control
   }: TextFieldProps) {
     return (
       <Field.Root className="field" name={control.name}>
         <Field.Label className="field__label">{label}</Field.Label>
         <Field.Control
           className="input"
           render={multiline ? <textarea rows={4} /> : undefined}
           onValueChange={onValueChange}
           {...control}
         />
         {hint ? (
           <Field.Description className="field__hint">{hint}</Field.Description>
         ) : null}
         <Field.Error className="field__error" match="valueMissing">
           This is required.
         </Field.Error>
       </Field.Root>
     );
   }
   ```
   
   src/ui/DeckSelect.tsx
   
   ```
   import { Field } from "@base-ui/react/field";
   import { Select } from "@base-ui/react/select";
   
   interface DeckSelectProps {
     decks: string[];
     value: string;
     onValueChange: (deck: string) => void;
   }
   
   export function DeckSelect({ decks, value, onValueChange }: DeckSelectProps) {
     return (
       <Field.Root className="field" name="deck">
         <Select.Root
           items={decks.map((deck) => ({ label: deck, value: deck }))}
           value={value}
           onValueChange={(deck) => deck && onValueChange(deck)}
         >
           <Select.Label className="field__label">Deck</Select.Label>
           <Select.Trigger className="select__trigger">
             <Select.Value />
             <Select.Icon>▾</Select.Icon>
           </Select.Trigger>
           <Select.Portal>
             <Select.Positioner className="select__positioner" sideOffset={4}>
               <Select.Popup className="select__popup">
                 <Select.List>
                   {decks.map((deck) => (
                     <Select.Item key={deck} value={deck} className="select__item">
                       <Select.ItemIndicator className="select__check">
                         ✓
                       </Select.ItemIndicator>
                       <Select.ItemText>{deck}</Select.ItemText>
                     </Select.Item>
                   ))}
                 </Select.List>
               </Select.Popup>
             </Select.Positioner>
           </Select.Portal>
         </Select.Root>
       </Field.Root>
     );
   }
   ```
7. Write the options page
   
   A form with three fields. When the server is not `ankinize.com`, saving first asks the browser for access to that origin. The request has to start from the click, so it comes before anything else in the handler. Base UI's `Form` checks the required fields before it calls you.
   
   src/entrypoints/options/App.tsx
   
   ```
   import { Button } from "@base-ui/react/button";
   import { Form } from "@base-ui/react/form";
   import { useEffect, useState } from "react";
   import { browser } from "wxt/browser";
   import { DEFAULT_BASE_URL, getSettings, saveSettings } from "@/lib/settings";
   import { TextField } from "@/ui/Field";
   
   export function App() {
     const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
     const [apiKey, setApiKey] = useState("");
     const [deck, setDeck] = useState("");
     const [message, setMessage] = useState("");
   
     useEffect(() => {
       getSettings().then((saved) => {
         setBaseUrl(saved.baseUrl);
         setApiKey(saved.apiKey);
         setDeck(saved.deck);
       });
     }, []);
   
     async function save() {
       const origin = new URL(baseUrl).origin;
       // A server of your own needs its origin granted once; ankinize.com already
       // is. This must run straight from the click, so it comes first.
       if (origin !== DEFAULT_BASE_URL) {
         const granted = await browser.permissions.request({
           origins: [`${origin}/*`],
         });
         if (!granted) return setMessage("Access to that server was not granted.");
       }
       await saveSettings({ baseUrl: origin, apiKey: apiKey.trim(), deck });
       setMessage("Saved.");
     }
   
     return (
       <main className="panel panel--page">
         <h1>Ankinize settings</h1>
         <Form
           className="panel"
           style={{ padding: 0, width: "auto", minHeight: 0 }}
           onFormSubmit={() => void save()}
         >
           <TextField
             required
             type="url"
             name="baseUrl"
             label="Server"
             value={baseUrl}
             onValueChange={setBaseUrl}
             hint="https://ankinize.com, or your own server."
           />
           <TextField
             required
             type="password"
             name="apiKey"
             label="API key"
             value={apiKey}
             onValueChange={setApiKey}
             hint="Make a key with the write scope under API Keys & Devices. Use one just for this extension, so you can revoke it alone."
           />
           <TextField
             name="deck"
             label="Starting deck"
             value={deck}
             onValueChange={setDeck}
             hint="The popup starts here. You can change it for each card."
           />
           <Button type="submit" className="button">
             Save
           </Button>
           <p className="status" role="status">
             {message}
           </p>
         </Form>
       </main>
     );
   }
   ```
8. Send the card to Ankinize
   
   One function adds a Basic card, and the popup and the widget both use it. The `idempotencyKey` makes a double click or a retry safe: the second request returns the first answer instead of adding another card. Errors from the API arrive as an `AnkinizeError` with a readable message, such as a deck that does not exist or a key without the write scope.
   
   src/lib/export.ts
   
   ```
   import { AnkinizeError, createNote } from "@ankinize/sdk";
   import { useServer } from "./api";
   import { basicFields, type CardInput } from "./card";
   import type { Settings } from "./settings";
   
   /** Adds one Basic card to `deck`. Throws an `AnkinizeError` the API refuses. */
   export async function addCard(
     settings: Settings,
     { deck, ...card }: CardInput & { deck: string },
   ) {
     useServer(settings);
     await createNote({
       notetypeName: "Basic",
       deckName: deck,
       fields: basicFields(card),
       tags: ["from-browser"],
       // A retry of this same click must not add the card twice.
       idempotencyKey: crypto.randomUUID(),
     });
   }
   
   /** A message a person can read, for whatever went wrong. */
   export function describeError(error: unknown) {
     if (error instanceof AnkinizeError) return error.message;
     return error instanceof Error ? error.message : "The card was not added.";
   }
   ```
9. Add the background script
   
   A page's scripts must never see your API key, and a content script's requests are subject to the page's CORS rules, so the widget cannot call the API itself. It sends the selection to the extension's service worker, which holds the key and has the host permission, and answers with the result. Returning `true` from the listener keeps the channel open for that later answer.
   
   src/entrypoints/background.ts
   
   ```
   import { browser } from "wxt/browser";
   import { defineBackground } from "wxt/utils/define-background";
   import { addCard, describeError } from "@/lib/export";
   import {
     type ExportRequest,
     type ExportResponse,
     isExportRequest,
   } from "@/lib/messages";
   import { getSettings } from "@/lib/settings";
   
   /**
    * The page widget cannot call the API itself: a content script's requests are
    * subject to the page's CORS rules, and the key must stay out of pages. So it
    * asks this service worker, which holds the key and has the host permission.
    */
   async function exportSelection(
     request: ExportRequest,
   ): Promise<ExportResponse> {
     const settings = await getSettings();
     if (!settings.apiKey) {
       await browser.runtime.openOptionsPage();
       return { ok: false, message: "Add your API key in the settings first." };
     }
     try {
       await addCard(settings, {
         front: request.text,
         back: "",
         deck: settings.deck,
         source: { title: request.title, url: request.url },
       });
       return { ok: true, deck: settings.deck };
     } catch (error) {
       return { ok: false, message: describeError(error) };
     }
   }
   
   export default defineBackground(() => {
     browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
       if (!isExportRequest(message)) return;
       exportSelection(message).then(sendResponse);
       return true; // the answer comes later
     });
   });
   ```
   
   The message types are the small `src/lib/messages.ts` in the repository.
10. Add the selection widget
    
    A content script runs inside pages. `createShadowRootUi` mounts our React tree in a shadow root, and `cssInjectionMode: "ui"` puts the stylesheet inside it, so the page cannot restyle the button and the button cannot leak styles into the page. Matching `<all_urls>` means the script is loaded on every page, so it only watches for a selection and reads nothing else.
    
    src/entrypoints/selection-widget.content/index.tsx
    
    ```
    import { createRoot } from "react-dom/client";
    import { defineContentScript } from "wxt/utils/define-content-script";
    import { createShadowRootUi } from "wxt/utils/content-script-ui/shadow-root";
    import "./style.css";
    import { SelectionWidget } from "./SelectionWidget";
    
    export default defineContentScript({
      matches: ["<all_urls>"],
      // Put the stylesheet inside the shadow root, so the page cannot restyle the
      // widget and the widget cannot leak styles into the page.
      cssInjectionMode: "ui",
      async main(ctx) {
        const ui = await createShadowRootUi(ctx, {
          name: "ankinize-selection-widget",
          position: "inline",
          anchor: "body",
          onMount(container) {
            const root = createRoot(container);
            root.render(<SelectionWidget />);
            return root;
          },
          onRemove(root) {
            root?.unmount();
          },
        });
        ui.mount();
      },
    });
    ```
    
    The widget shows the button after you finish a selection (on mouse up or a key up), puts it under the end of the selection, and hides it when the selection clears. Pressing the button does not clear the selection, because the container cancels `mousedown`. The button is Base UI's `Button`, a plain `<button>` with the disabled state handled for you; the icon changes to a check when the card is added, or a cross with the reason when it is not. Its stylesheet, `style.css`, is in the [repository](https://github.com/0x0501/ankinize-examples/tree/main/browser-extension/src/entrypoints/selection-widget.content/style.css).
    
    src/entrypoints/selection-widget.content/SelectionWidget.tsx
    
    ```
    import { Button } from "@base-ui/react/button";
    import { useEffect, useState } from "react";
    import { browser } from "wxt/browser";
    import type { ExportRequest, ExportResponse } from "@/lib/messages";
    
    interface Anchor {
      text: string;
      left: number;
      top: number;
    }
    
    type Result = {
      text: string;
      state: "busy" | "done" | "error";
      message: string;
    };
    
    const SIZE = 32;
    
    /** Where the button goes: under the end of the selection, or above it at the page's foot. */
    function readAnchor(): Anchor | null {
      const selection = window.getSelection();
      const text = selection?.toString().trim();
      if (!selection || !text || selection.rangeCount === 0) return null;
      const rect = selection.getRangeAt(0).getBoundingClientRect();
      const below = rect.bottom + 8;
      return {
        text,
        left: Math.max(8, Math.min(rect.right, window.innerWidth - SIZE - 8)),
        top: below + SIZE > window.innerHeight ? rect.top - SIZE - 8 : below,
      };
    }
    
    export function SelectionWidget() {
      const [anchor, setAnchor] = useState<Anchor | null>(null);
      const [result, setResult] = useState<Result>();
    
      useEffect(() => {
        // After the click that ends a selection, the browser may still be
        // collapsing the old one; read it on the next turn.
        const read = () => setTimeout(() => setAnchor(readAnchor()), 0);
        const reposition = () => setAnchor((current) => current && readAnchor());
        const hideWhenCleared = () => {
          if (!window.getSelection()?.toString().trim()) setAnchor(null);
        };
    
        document.addEventListener("mouseup", read);
        document.addEventListener("keyup", read);
        document.addEventListener("selectionchange", hideWhenCleared);
        window.addEventListener("scroll", reposition, true);
        window.addEventListener("resize", reposition);
        return () => {
          document.removeEventListener("mouseup", read);
          document.removeEventListener("keyup", read);
          document.removeEventListener("selectionchange", hideWhenCleared);
          window.removeEventListener("scroll", reposition, true);
          window.removeEventListener("resize", reposition);
        };
      }, []);
    
      if (!anchor) return null;
    
      // A result belongs to the text it was made for.
      const current = result?.text === anchor.text ? result : undefined;
      const state = current?.state;
    
      async function exportSelection() {
        if (!anchor) return;
        const { text } = anchor;
        setResult({ text, state: "busy", message: "Adding…" });
        const request: ExportRequest = {
          type: "ankinize:export",
          text,
          title: document.title,
          url: location.href,
        };
        try {
          const response: ExportResponse =
            await browser.runtime.sendMessage(request);
          setResult(
            response.ok
              ? { text, state: "done", message: `Added to ${response.deck}` }
              : { text, state: "error", message: response.message },
          );
        } catch {
          setResult({
            text,
            state: "error",
            message: "The extension was reloaded. Refresh the page.",
          });
        }
      }
    
      const label = current?.message ?? "Add to Anki";
    
      return (
        <div
          className="widget"
          style={{ left: anchor.left, top: anchor.top }}
          // Keep the selection: pressing a button would otherwise clear it.
          onMouseDown={(event) => event.preventDefault()}
        >
          <Button
            className="widget__button"
            aria-label={label}
            title={label}
            data-state={state}
            disabled={state === "busy" || state === "done"}
            onClick={exportSelection}
          >
            <Icon state={state} />
          </Button>
          <span className="widget__status" role="status">
            {current && current.state !== "busy" ? current.message : ""}
          </span>
        </div>
      );
    }
    
    function Icon({ state }: { state: Result["state"] | undefined }) {
      const path =
        state === "done"
          ? "M5 12.5l4.5 4.5L19 7.5" // check
          : state === "error"
            ? "M6 6l12 12M18 6L6 18" // cross
            : "M12 4v11m0 0l-4-4m4 4l4-4M5 19h14"; // arrow into a tray
      return (
        <svg
          width="18"
          height="18"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2.2"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d={path} />
        </svg>
      );
    }
    ```
11. Write the popup
    
    On open it loads the settings and the selection together, then lists your decks with `listDecks`. Pressing *Add card* calls `addCard` with the front and back you edited and the deck you chose. Use the popup when you want to reword the card or pick another deck; use the widget when the selection is already the card.
    
    src/entrypoints/popup/App.tsx
    
    ```
    import { listDecks } from "@ankinize/sdk";
    import { Button } from "@base-ui/react/button";
    import { Checkbox } from "@base-ui/react/checkbox";
    import { useEffect, useState } from "react";
    import { browser } from "wxt/browser";
    import { useServer } from "@/lib/api";
    import { addCard, describeError } from "@/lib/export";
    import { type PageSelection, readSelection } from "@/lib/selection";
    import { getSettings, type Settings } from "@/lib/settings";
    import { DeckSelect } from "@/ui/DeckSelect";
    import { TextField } from "@/ui/Field";
    
    type Status =
      | { tone: "idle" }
      | { tone: "busy" }
      | { tone: "done"; deck: string }
      | { tone: "error"; message: string };
    
    export function App() {
      const [settings, setSettings] = useState<Settings>();
      const [page, setPage] = useState<PageSelection>();
      const [decks, setDecks] = useState<string[]>([]);
      const [front, setFront] = useState("");
      const [back, setBack] = useState("");
      const [deck, setDeck] = useState("");
      const [withLink, setWithLink] = useState(true);
      const [status, setStatus] = useState<Status>({ tone: "idle" });
    
      useEffect(() => {
        (async () => {
          const [saved, selection] = await Promise.all([
            getSettings(),
            readSelection(),
          ]);
          setSettings(saved);
          setPage(selection);
          setFront(selection.text);
          setDeck(saved.deck);
          if (!saved.apiKey) return;
    
          useServer(saved);
          try {
            const { decks } = await listDecks();
            const names = decks.map((d) => d.name).sort();
            setDecks(names);
            // Keep the saved deck if it still exists, else start on the first.
            setDeck((current) =>
              names.includes(current) ? current : (names[0] ?? ""),
            );
          } catch (error) {
            setStatus({ tone: "error", message: describeError(error) });
          }
        })();
      }, []);
    
      if (!settings || !page) return <main className="panel" />;
    
      if (!settings.apiKey) {
        return (
          <main className="panel">
            <h1>Connect Ankinize</h1>
            <p className="status">Add your API key to start making cards.</p>
            <Button
              className="button"
              onClick={() => browser.runtime.openOptionsPage()}
            >
              Open settings
            </Button>
          </main>
        );
      }
    
      async function add() {
        if (!page || !settings) return;
        setStatus({ tone: "busy" });
        try {
          await addCard(settings, {
            front,
            back,
            deck,
            source: withLink ? page : undefined,
          });
          setStatus({ tone: "done", deck });
        } catch (error) {
          setStatus({ tone: "error", message: describeError(error) });
        }
      }
    
      const done = status.tone === "done";
    
      return (
        <main className="panel">
          <h1>Add to Anki</h1>
          <form
            className="panel"
            style={{ padding: 0, width: "auto", minHeight: 0 }}
            onSubmit={(event) => {
              event.preventDefault();
              void add();
            }}
          >
            <TextField
              multiline
              required
              name="front"
              label="Front"
              value={front}
              onValueChange={setFront}
              placeholder="Select text on the page, then open this again"
            />
            <TextField
              multiline
              name="back"
              label="Back"
              value={back}
              onValueChange={setBack}
              hint="Optional. The page link goes under it."
            />
            <div className="checkbox-row">
              <Checkbox.Root
                className="checkbox"
                id="with-link"
                checked={withLink}
                onCheckedChange={setWithLink}
              >
                <Checkbox.Indicator>✓</Checkbox.Indicator>
              </Checkbox.Root>
              <label htmlFor="with-link">Link to this page</label>
            </div>
            <DeckSelect decks={decks} value={deck} onValueChange={setDeck} />
            <Button
              type="submit"
              className="button"
              disabled={!front.trim() || !deck || status.tone === "busy" || done}
            >
              {status.tone === "busy" ? "Adding…" : "Add card"}
            </Button>
          </form>
          <p
            className="status"
            role="status"
            data-tone={status.tone === "error" ? "error" : undefined}
          >
            {status.tone === "done" &&
              `Added to ${status.deck}. It syncs to your devices.`}
            {status.tone === "error" && status.message}
          </p>
        </main>
      );
    }
    ```
    
    The popup's `main.tsx`, the two `index.html` files and `src/lib/api.ts` (which points the SDK at the saved server) are a few lines each; copy them from the repository.
12. Run it
    
    `bun run dev` opens a browser with the extension loaded and reloads it as you edit. Open the settings from the extension's menu, enter your key, select some text on a page and click the export button beside it (reload tabs that were open before you installed the extension). The card appears in your collection at once, and on your other devices at their next sync. The toolbar popup works too.
    
    Terminal
    
    ```
    bun run dev          # Chrome, with live reload
    bun run dev:firefox  # the same in Firefox
    bun run build        # .output/chrome-mv3, for "Load unpacked"
    bun run zip          # a package for the store
    ```

> Use a key made for the extension
> 
> A key in an extension can write to your whole collection. Make a separate key with the `write` scope so you can revoke it alone, and never publish an extension with a key built in: each user enters their own in the options page.

## Make it your own

- **Other note types.** Use `getNoteType` to list a type's field keys, then change `notetypeName` and `fields` to match.
- **A right-click menu.** Add the `contextMenus` permission and a background script that calls `createNote` straight from the selection.
- **Check for duplicates.** Before adding, search with `listNotes` and a `q` built with `q.field` to see whether the front already exists.

## Next

Let an agent do the choosing: [AI agents and Ankinize](https://ankinize.com/docs/agents). Every endpoint is in the [API reference](https://ankinize.com/docs/api).
