Skip to content
AnkinizeOpenAPILog in
OverviewSync with AnkiAuthenticationAPI referenceTypeScript SDKAnkiConnect alternativeAI agentsMCP serverSelf-host

Tutorials

Browser extension

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 for the extension, Base UI for the interface, and the TypeScript 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 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.

    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.

    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. Every endpoint is in the API reference.

On this page

  • What you will build
  • Build it
  • Make it your own
  • Next