Appearance
Worker API
L2 code — the main and background scripts named in the manifest — runs in a dedicated, locked-down Worker per extension. It talks to Typora only through the global typora object described here.
| Entry | Instances | Use it for |
|---|---|---|
main | One per window | Anything tied to a document or the editor: commands, formatters, resolvers, detail-view checkers |
background | One for the whole app | Shared state across windows, caches and indexes, scheduled jobs |
Most extensions only need main. Tabs share their window, so main instances scale with windows, not documents.
Module shape
Entries are ES modules. They may import other modules from your extension.
js
import { countWords } from "./words.js";
export async function activate(ctx) {
// ctx.subscriptions: disposables cleaned up on disable / uninstall / reload
// ctx.extension: { id, name, version }
// ctx.scope: "main" or "background"
ctx.subscriptions.push(
typora.commands.register("count", async () => {
const words = countWords(await typora.editor.getMarkdown());
await typora.ui.toast(`${words} words`);
}),
);
}
export function deactivate() {
// optional: release anything not in ctx.subscriptions
}Every register…, on… and port returns a disposable ({ dispose() }). Push them to ctx.subscriptions; functions are accepted too.
The environment
The Worker has no network, no persistent storage and no way to reach other contexts on its own. At startup Typora removes every global that is not on an allowlist.
Available: ECMAScript built-ins, Intl, WebAssembly; TextEncoder / TextDecoder, URL / URLSearchParams, atob / btoa; setTimeout / setInterval / queueMicrotask; structuredClone, Blob, File, FileReader, streams, compression streams; AbortController, EventTarget, events, DOMException; crypto.getRandomValues, crypto.randomUUID, crypto.subtle; performance.now; Headers, Request, Response, FormData; OffscreenCanvas and 2D canvas types; console.
Removed, and what to use instead:
| Removed | Use instead |
|---|---|
XMLHttpRequest, network fetch | typora.net.fetch. A global fetch backed by it is installed, so most npm libraries keep working. |
WebSocket, EventSource, WebTransport | Not available. |
indexedDB, caches, navigator.storage | typora.storage and typora.storage.files. |
BroadcastChannel | typora.runtime ports between main and background. |
navigator.locks | typora.storage.update and typora.runtime.lock. |
importScripts, import() of URLs | Bundle your code. import() works only for your own files. |
Worker, SharedWorker | Not available. |
navigator.* | typora.env. navigator is a frozen stub with language, platform and userAgent: "Typora". |
console.log, info, warn, error and debug go to the extension's log (Settings ▸ Extensions ▸ View Log), as do uncaught errors and unhandled rejections.
WebAssembly runs in the same Worker. Read the module with typora.runtime.readAsset("x.wasm") and instantiate the bytes.
Typora waits up to 60 s for your answer to its own requests (a formatter, a resolver, a checker) and up to 10 minutes for a command handler. A Worker that fails to start is reported in the log.
Availability and permissions
| Namespace | main | background | Permission |
|---|---|---|---|
commands | ✓ | — | |
editor | ✓ | document: read to read, document: write to change | |
document | ✓ | document: read for getBlocks | |
iteration | ✓ | — | |
formatters | ✓ | — (formatters change the document through Typora) | |
views | ✓ | — | |
workspace | ✓ | workspace: read | |
settings | ✓ | ✓ | — |
storage | ✓ | ✓ | — |
net | ✓ | ✓ | network |
runtime | ✓ | ✓ | — |
alarms | ✓ | ✓ | — |
clipboard | ✓ | clipboard | |
images | ✓ | — | |
ai | ✓ | ai | |
process | ✓ | process | |
ui | ✓ | toast only¹ | — |
l10n | ✓ | ✓ | — |
¹ In the background, toast writes to the log.
A call without the needed permission rejects with an error such as "Hello has no document read permission." editor and document act on the active document of the window the call came from.
typora.extension and typora.env
js
typora.extension; // { id, name, version }
typora.env; // { platform, locale, appearance }commands
js
typora.commands.register(id, handler) → Disposable
typora.commands.execute(commandId) → Promise<void>register handles one of the commands declared in contributes.commands (Commands and Menus). execute runs a Typora command by id, e.g. paragraph.insertTable.
editor
js
typora.editor.getSelection() // → { anchor, focus, markdown } | null
typora.editor.getMarkdown() // → the whole document as Markdown
typora.editor.insertMarkdown(md) // insert at the caret / replace the selection
typora.editor.replaceRange(range, md) // range: { anchor: { blockId, offset }, focus: { blockId, offset } }
typora.editor.replaceDocument(md) // replace everything, as one undo stepRead calls need document: "read"; changes need document: "write". Inserted Markdown goes through the same path as paste, so multi-line Markdown becomes real blocks. Each change is one undo step. replaceDocument keeps the caret where the text around it is unchanged, also works in Source Code mode, and is refused in read-only documents.
document
js
typora.document.info() // → { path, name, dirty } | null
typora.document.getBlocks() // → [{ id, type, text, attrs, depth, parent }]
typora.document.readImage(src) // → { name, type, path, bytes } of a local image the document refers to
typora.document.onDidChange(({ blockIds }) => { … }) → DisposablegetBlocks (needs document: "read") returns every block in document order: type is a block type, text its Markdown source, attrs type-specific attributes (level for headings, checked for task items, lang for fences), depth and parent the nesting. onDidChange is debounced (300 ms) and reports the ids of the blocks that changed.
readImage (needs document: "read") reads an image file on this computer that the document refers to — src as written in the Markdown, such as images/a.png in  or a path in the front matter. Typora resolves it the way the editor shows it (relative to the document, honoring typora-root-url) and returns its bytes (Uint8Array) and MIME type. It rejects sources the document doesn't contain, remote URLs, files that aren't images, and very large files. The WordPress sample uploads a post's images with it.
iteration
js
typora.iteration.register(viewId, (block) => [{ label, from?, to?, group? }]) → DisposableSupplies the items of a detail view declared with "provider": "main". The checker is called with one block at a time and only for blocks that changed. See Detail Views.
formatters
js
typora.formatters.register(id, (markdown) => newMarkdown, { title }) → DisposableA formatter receives the document's Markdown and returns new Markdown (or a promise of it). It appears in the command palette as Format Document: title; if the result differs, Typora replaces the document as one undo step.
js
typora.formatters.register("spacing", addSpacing, { title: "CJK Spacing" });views
js
typora.views.registerFileViewer(viewerId, { resolve(file, panel) { … } }) → Disposable
typora.views.registerFenceResolver(rendererId, { resolve(fence, panel) { … } }) → DisposableResolve the page for a file viewer or a fence renderer with resolver: true.
fileis{ path, name, ext, readonly };fenceis{ source, info, purpose }.panel.entry— the page to load, a path inside your extension.panel.height— initial height in pixels (fences).panel.postMessage(message)/panel.onMessage(fn)— talk to the page (view.onMessage/view.postMessageon the other side).panel.onDidDispose(fn)— the page was closed.
workspace
js
typora.workspace.folders() // → [path] of the folder open in this window
typora.workspace.listFiles(dir?) // → file paths under the folder (or a sub-folder)
typora.workspace.readFile(path) // → text of a file inside the folderNeeds workspace: "read". Only paths inside the open folder are allowed. listFiles skips hidden files and stops at 5,000 files or 8 levels deep.
settings
js
typora.settings.get("key") // one value
typora.settings.get() // all values, defaults filled in
typora.settings.onDidChange((values) => { … }) → DisposableReads the extension's own configuration. Extensions can't write settings.
storage
js
await typora.storage.set("days", { "2026-10-09": 512 });
const days = await typora.storage.get("days"); // undefined if missing
await typora.storage.remove("days");
const keys = await typora.storage.keys();
// Atomic read-modify-write across every window and the background:
await typora.storage.update("days", (days = {}) => ({ ...days, [today()]: (days[today()] ?? 0) + 1 }));Values are JSON. Storage is per extension, shared by all of its windows, its background and its pages, and removed on uninstall. Key/value storage is limited to about 5 MB per extension.
Private files for larger or binary data (up to about 100 MB per extension in total):
js
await typora.storage.files.write("cache/index.bin", bytes); // string, Uint8Array or ArrayBuffer
const bytes = await typora.storage.files.read("cache/index.bin"); // Uint8Array
const text = await typora.storage.files.readText("notes.txt");
await typora.storage.files.delete("cache/index.bin");
const list = await typora.storage.files.list();secrets
For passwords, tokens and API keys. Use this instead of storage, which is plain text on disk.
js
await typora.secrets.set("token", value); // a string
const token = await typora.secrets.get("token"); // null if missing
await typora.secrets.delete("token");
const keys = await typora.secrets.keys();Secrets are per extension and removed on uninstall. They stay on this computer: copying the profile doesn't bring them along. They go to the OS credential store: the Keychain on macOS and iOS, DPAPI on Windows, the desktop keyring on Linux.
A Linux desktop may have no keyring. Then set throws until the user agrees to save without encryption, which they can do from a format: "secret" field in your options, so take credentials there. Never fall back to storage.
net
js
const response = await typora.net.fetch("https://kroki.io/plantuml/svg", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: source,
timeoutMs: 20_000,
});
const svg = await response.text();Returns a standard Response. Only origins in the extension's network permission are allowed. The request is made by Typora, so CORS does not apply. The global fetch is the same function. Responses are limited to about 20 MB.
runtime
js
typora.runtime.scope // "main" or "background"
await typora.runtime.readAsset(path) // → Uint8Array of a file in your extension
await typora.runtime.lock(name, fn) // run fn while holding a lock shared by all instances
await typora.runtime.windows() // → ids of open windows
typora.runtime.onWindowsChanged((ids) => { … })Ports between main and background
runtime.connect() from a window's main returns a port to the same extension's background. The background receives one port per connecting window in onConnect. Extensions can't connect to each other.
js
// main.js
export async function activate(ctx) {
const port = await typora.runtime.connect();
ctx.subscriptions.push(port);
ctx.subscriptions.push(
typora.document.onDidChange(async () => {
await port.postMessage({ type: "changed" });
}),
);
}js
// background.js
export function activate(ctx) {
ctx.subscriptions.push(
typora.runtime.onConnect((port) => {
// port.remote is the id of the window on the other end
port.onMessage(async (message) => {
if (message.type === "changed") await port.postMessage({ type: "ack" });
});
port.onDisconnect(() => { /* window closed */ });
}),
);
}A port has postMessage(message), onMessage(fn), onDisconnect(fn), remote and dispose(). Keep messages JSON-serializable.
alarms
js
await typora.alarms.create("prune", { delayInMinutes: 1, periodInMinutes: 360 });
await typora.alarms.create("once", { when: Date.now() + 60_000 });
await typora.alarms.clear("prune");
typora.alarms.onAlarm((name) => { … }) → DisposableUse alarms for scheduled work in background: the background runs in a hidden view where setTimeout and setInterval are throttled by the system and fire late. Alarms are driven by Typora's native timers. periodInMinutes is at least 1.
images
js
typora.images.registerUploader(id, async (images) => urls) → DisposableHandles one of your imageUploaders. images is [{ name, type, path, bytes }]; return their URLs in the same order.
clipboard
js
const text = await typora.clipboard.readText();
await typora.clipboard.writeText("Copied!");Needs clipboard: true.
ai
js
const { ready, problem, model } = await typora.ai.status();
if (!ready) return typora.ui.toast(problem); // e.g. "Add a model provider in Settings ▸ Intelligence."
const reply = await typora.ai.complete({
system: "You write short, factual summaries.",
prompt: `Summarize in one sentence:\n\n${await typora.editor.getMarkdown()}`,
maxTokens: 200,
});Asks the model the user set up in Settings ▸ Intelligence — any OpenAI-compatible server, or a coding agent such as Claude Code — and resolves with its whole reply as text. Needs ai: true. Pass prompt for one question, or messages ([{ role: "user" | "assistant", content }]) for a conversation; system is optional. At most 400,000 characters per request.
complete rejects with a message meant for the user when AI features are off, no model is set up, or the server fails. Models don't always follow format instructions: if you ask for JSON, also accept it inside a ```json fence or with text around it. Requests are cancelled when your extension stops. They can take a while; toast first so the user knows something is happening.
process
Run programs. Needs the process permission, and Settings ▸ Extensions ▸ Allow extensions to run programs must be on (the default).
js
const shell = await typora.process.spawn(typora.process.shell, [], {
pty: { cols: 80, rows: 24 }, // a pseudo-terminal; leave out for pipes (or pass true for 80×24)
cwd: "/Users/me/notes", // default: the folder open in the window, else home
env: { NO_COLOR: "1" }, // added to the environment
name: "terminal", // your label, returned by list()
});
shell.onData((bytes, stream) => …); // Uint8Array; stream is "stdout" / "stderr" with pipes, undefined with a pty
shell.onExit(({ code, signal }) => …);
await shell.write("ls\r"); // string or bytes
await shell.resize(100, 30); // pty only
await shell.end(); // close stdin (pipes); ^D in a pty
await shell.kill(); // "SIGTERM" by default; also SIGKILL, SIGINT, SIGHUP, SIGQUIT
shell.running; shell.exitCode; shell.signal; shell.pid;
const git = await typora.process.spawn("git", ["status", "--short"]);
const helper = await typora.process.spawn({ binary: "helper" }, ["--version"]);
await typora.process.list(); // [{ id, pid, command, args, name, pty, running, exitCode, signal }]
const again = await typora.process.attach(id); // again.output: what it printed recently
await typora.process.info(); // { os, arch, shell }The command is typora.process.shell (the user's login shell), a command name, an absolute path, or { binary } from your binaries. With a list permission only those names and your binaries are allowed.
Programs belong to your extension in the window, not to the script or page that started them: your main and your pages see the same ones through list() and attach(), which replays up to 512 KB of recent output. They end when they exit, when you kill them, when your extension stops in the window (switched off, uninstalled, reloaded), or when the window closes or reloads. Killing reaches the program's whole process group (its children too); a program still there 3 seconds after a polite signal gets SIGKILL. On Windows SIGINT sends Ctrl+C to a pty and every other signal ends the process tree.
Programs see Typora's environment with the user's login-shell PATH (a login shell sets its own), TERM=xterm-256color, COLORTERM=truecolor and TERM_PROGRAM=Typora in a pty, then your env. At most 16 run at once per extension and window. Not available in background.
ui
js
await typora.ui.toast("Saved.");
const choice = await typora.ui.quickPick(["One", "Two", { label: "Three", description: "the last", value: 3 }], { placeholder: "Pick one" });
const name = await typora.ui.input({ prompt: "Name?", value: "world", placeholder: "Your name" }); // string or null
const ok = await typora.ui.confirm("Replace the whole document?"); // booleanDialogs are labelled with your extension's name.
l10n
js
typora.l10n.t("greeting", name) // "Hello, {0}!" → "Hello, Ada!"Point "l10n" in the manifest at a folder of <locale>.json files (en.json, zh-CN.json, …), each mapping keys to strings with {0}, {1} placeholders. Typora picks the file for the app's language, falling back to the base language and then en. Missing keys return the key itself.
background
A background script runs once for the whole app, in a hidden host with no windows of its own. It keeps running while the extension is enabled — on macOS even with no windows open.
- It has no document or editor:
editor,document,iteration,formatters,views,workspace,clipboardand theuidialogs are unavailable. Ask a window'smainto do document work through a port. - Use
alarmsfor anything periodic. - Command-line export never starts the background, and
runtime.connect()rejects there. Fence renderers, resolvers and formatters must work without it.
See the Writing Stats sample for main + background with ports, atomic storage and alarms.
Publish
typora.publish.registerAdapter(id, handler) registers a declared publish adapter and returns a disposable. typora.publish.current(id, options) publishes the current live document or queries status through the shared core. See Publish adapters for the request and result types, capabilities and CLI contributor contract.