Skip to content

Extension API

This page documents every API surface available to Stina extensions at runtime. All types are exported from the @stina/extension-api package.

Every extension must default-export an ExtensionModule. Stina calls activate() when the extension loads and deactivate() when it is unloaded.

import type { ExtensionModule, ExtensionContext } from '@stina/extension-api'
const extension: ExtensionModule = {
activate(context: ExtensionContext) {
// Register tools, providers, actions, scheduled jobs, etc.
// Optionally return a Disposable for cleanup.
},
deactivate() {
// Optional teardown logic.
}
}
export default extension
MemberSignatureDescription
activate(context: ExtensionContext) => void | Disposable | Promise<void | Disposable>Called once when the extension is loaded. Use the context to register everything the extension contributes.
deactivate() => void | Promise<void>(optional) Called when the extension is unloaded. Use this for final cleanup if you did not return a Disposable from activate.

If activate() returns a Disposable, its dispose() method is called automatically when the extension is deactivated.


The context object passed to activate() is the extension’s gateway to every Stina API. Each API property is only present if the extension has declared the corresponding permission in its manifest.

interface ExtensionContext {
readonly extension: { id: string; version: string; storagePath: string }
readonly log: LogAPI // Always available
readonly network?: NetworkAPI // Requires network:* permission
readonly settings?: SettingsAPI // Requires settings.register
readonly providers?: ProvidersAPI // Requires provider.register
readonly tools?: ToolsAPI // Requires tools.register
readonly actions?: ActionsAPI // Requires actions.register
readonly events?: EventsAPI // Requires events.emit
readonly scheduler?: SchedulerAPI // Requires scheduler.register
readonly user?: UserAPI // Requires user.profile.read
readonly chat?: ChatAPI // Requires chat.message.write
readonly storage?: StorageAPI // Requires storage.collections
readonly secrets?: SecretsAPI // Requires secrets.manage
readonly backgroundWorkers?: BackgroundWorkersAPI // Requires background.workers
}
PropertyRequired permissionPurpose
extension(none)Read-only metadata: id, version, and storagePath (writable directory on disk).
log(none)Logging. Always available.
networknetwork:* or network:<domain>HTTP requests.
settingssettings.registerRead/write extension settings.
providersprovider.registerRegister AI providers.
toolstools.registerRegister tools the AI can call.
actionsactions.registerRegister backend handlers for UI components.
eventsevents.emitEmit events to trigger UI refreshes.
schedulerscheduler.registerSchedule one-time or recurring jobs.
useruser.profile.readRead the current user’s profile.
chatchat.message.writeAppend instructions to conversations.
storagestorage.collectionsDocument storage (activation-time only).
secretssecrets.manageEncrypted key-value storage (activation-time only).
backgroundWorkersbackground.workersLong-running background tasks.

Logging is always available, no permission required. Log output appears in Stina’s extension log viewer.

interface LogAPI {
debug(message: string, data?: Record<string, unknown>): void
info(message: string, data?: Record<string, unknown>): void
warn(message: string, data?: Record<string, unknown>): void
error(message: string, data?: Record<string, unknown>): void
}

All four methods accept an optional data object that is attached as structured metadata alongside the message.

Example:

context.log.info('Extension activated')
context.log.debug('Loaded config', { accountCount: 3 })
context.log.error('Connection failed', { host: 'imap.example.com', code: 'ETIMEDOUT' })

Permission: network:* (all hosts) or network:<domain> (specific host).

All HTTP traffic goes through Stina’s network layer, which enforces the domain restrictions declared in the manifest.

interface NetworkAPI {
fetch(url: string, options?: RequestInit): Promise<Response>
fetchStream(url: string, options?: RequestInit): AsyncGenerator<string, void, unknown>
}
MethodDescription
fetchStandard fetch. Returns a Response identical to the Web Fetch API.
fetchStreamStreaming fetch for NDJSON, SSE, or chunked responses. Yields text chunks as they arrive.

Example — standard request:

const response = await context.network!.fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer token' }
})
const data = await response.json()

Example — streaming:

let buffer = ''
for await (const chunk of context.network!.fetchStream(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }
})) {
buffer += chunk
// Process partial results as they arrive
}

fetchStream throws an Error if the request fails or encounters a network error.


Permission: provider.register

Register an AI provider that Stina can use for chat completions, model listing, and optionally embeddings.

interface ProvidersAPI {
register(provider: AIProvider): Disposable
}
interface AIProvider {
id: string
name: string
getModels(options?: GetModelsOptions): Promise<ModelInfo[]>
chat(messages: ChatMessage[], options: ChatOptions): AsyncGenerator<StreamEvent, void, unknown>
embed?(texts: string[]): Promise<number[][]>
}
MemberDescription
idMust match the provider ID declared in the manifest.
nameDisplay name shown to the user.
getModelsReturn a list of models available from this provider.
chatStream a chat completion. Must yield StreamEvent objects.
embed(optional) Generate vector embeddings for a list of texts.

ModelInfo — describes a single model:

interface ModelInfo {
id: string
name: string
description?: string
contextLength?: number
}

ChatMessage — a message in the conversation:

interface ChatMessage {
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
tool_calls?: ToolCall[] // Present on assistant messages when the model calls tools
tool_call_id?: string // Present on tool messages as the response to a tool call
}

ToolCall — a tool invocation requested by the model:

interface ToolCall {
id: string
name: string
arguments: Record<string, unknown>
}

ChatOptions — controls for a chat request:

interface ChatOptions {
model?: string
temperature?: number // 0 to 1
maxTokens?: number
signal?: AbortSignal // For cancellation
settings?: Record<string, unknown> // Provider-specific settings from model configuration
tools?: ToolDefinition[] // Tools available for this request
}

GetModelsOptions — options passed to getModels:

interface GetModelsOptions {
settings?: Record<string, unknown> // Provider-specific settings (e.g., URL for Ollama)
}

StreamEvent — events yielded during streaming:

type StreamEvent =
| { type: 'content'; text: string }
| { type: 'thinking'; text: string }
| { type: 'tool_start'; name: string; input: unknown; toolCallId: string }
| { type: 'tool_end'; name: string; output: unknown; toolCallId: string }
| { type: 'done'; usage?: { inputTokens: number; outputTokens: number } }
| { type: 'error'; message: string }
EventWhen it is yielded
contentThe model produced text output.
thinkingThe model produced reasoning/thinking text (chain-of-thought).
tool_startThe model is calling a tool.
tool_endA tool call has completed.
doneGeneration is finished. Token usage may be included.
errorAn error occurred during generation.

Example — registering a provider:

context.providers?.register({
id: 'my-provider',
name: 'My AI Provider',
async getModels() {
return [{ id: 'model-1', name: 'Model One', contextLength: 8192 }]
},
async *chat(messages, options) {
const response = await callExternalAPI(messages, options)
for await (const token of response.stream) {
yield { type: 'content', text: token }
}
yield { type: 'done', usage: { inputTokens: 100, outputTokens: 50 } }
}
})

Permission: tools.register

Register tools that the AI assistant can call during conversations. Each tool must have a matching entry in contributes.tools in the manifest.

interface ToolsAPI {
register(tool: Tool): Disposable
}
interface Tool {
id: string
name: string
description: string
parameters?: Record<string, unknown> // JSON Schema
execute(params: Record<string, unknown>, context: ExecutionContext): Promise<ToolResult>
}
MemberDescription
idMust match the tool ID in the manifest’s contributes.tools.
nameDisplay name for the tool.
descriptionDescription that helps the AI understand when and how to use the tool.
parametersA JSON Schema object describing the expected input.
executeCalled when the AI invokes the tool. Receives parameters and an ExecutionContext.
interface ToolResult {
success: boolean
data?: unknown // Structured result data the AI can interpret
message?: string // Human-readable summary
error?: string // Error description if success is false
}

Example:

context.tools?.register({
id: 'search-contacts',
name: 'Search Contacts',
description: 'Search the user\'s contact list by name or email',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search term' },
limit: { type: 'number', description: 'Max results', default: 10 }
},
required: ['query']
},
async execute(params, context) {
const contacts = await context.userStorage.find('contacts', {
name: { $contains: params.query as string }
}, { limit: (params.limit as number) || 10 })
return {
success: true,
data: contacts,
message: `Found ${contacts.length} contacts`
}
}
})

Permission: actions.register

Register backend handlers that UI components (buttons, forms, settings views) can invoke. Unlike tools, actions are triggered by user interactions in the UI, not by the AI.

interface ActionsAPI {
register(action: Action): Disposable
}
interface Action {
id: string
execute(params: Record<string, unknown>, context: ExecutionContext): Promise<ActionResult>
}
MemberDescription
idUnique action ID within the extension. Referenced from UI component definitions.
executeCalled when the UI triggers the action. Parameters come from the component, with $-prefixed dynamic values already resolved.
interface ActionResult {
success: boolean
data?: unknown // Data returned to the UI
error?: string // Error message if success is false
}

Example:

context.actions?.register({
id: 'save-account',
async execute(params, context) {
const { email, password } = params as { email: string; password: string }
await context.userSecrets.set(`account-${email}`, password)
await context.userStorage.put('accounts', email, { email, enabled: true })
return { success: true, data: { email } }
}
})

The ExecutionContext is passed to every execute() call in tools, actions, and scheduler callbacks. It provides request-scoped access to storage, secrets, and metadata. Using per-request context (instead of global state) eliminates race conditions and makes multi-user setups safe.

interface ExecutionContext {
readonly userId?: string
readonly extension: { id: string; version: string; storagePath: string }
readonly storage: StorageAPI
readonly userStorage: StorageAPI
readonly secrets: SecretsAPI
readonly userSecrets: SecretsAPI
}
PropertyDescription
userIdThe ID of the user who triggered this request. Always defined for tool calls, action calls, and scheduler callbacks. Undefined only during activation-time operations.
extensionRead-only extension metadata.
storageExtension-scoped document storage, shared across all users.
userStorageUser-scoped document storage, isolated per user.
secretsExtension-scoped encrypted secrets, shared across all users.
userSecretsUser-scoped encrypted secrets, isolated per user.

In a single-user desktop setup, storage and userStorage behave the same way. In multi-user (server) deployments, userStorage keeps each user’s data separate.

Example:

async execute(params, context) {
// Extension-wide config (same for everyone)
const config = await context.storage.get('settings', 'api-config')
// Per-user preferences
const prefs = await context.userStorage.get('preferences', 'display')
// Extension-wide secret
const sharedKey = await context.secrets.get('shared-api-key')
// Per-user secret
const userToken = await context.userSecrets.get('oauth-token')
return { success: true, data: { configured: !!config } }
}

Permission: settings.register

Read and write extension settings. Settings declared in contributes.settings in the manifest are automatically rendered in Stina’s settings UI.

interface SettingsAPI {
getAll<T extends Record<string, unknown>>(): Promise<T>
get<T>(key: string): Promise<T | undefined>
set(key: string, value: unknown): Promise<void>
onChange(callback: (key: string, value: unknown) => void): Disposable
}
MethodDescription
getAll()Returns all settings for this extension as a typed object.
get(key)Returns the value of a single setting, or undefined if not set.
set(key, value)Updates a setting value.
onChange(callback)Subscribes to setting changes. The callback receives the changed key and its new value. Returns a Disposable to unsubscribe.

Example:

const settings = await context.settings!.getAll<{ apiUrl: string; timeout: number }>()
context.log.info('Current API URL', { url: settings.apiUrl })
// React to setting changes
context.settings!.onChange((key, value) => {
if (key === 'apiUrl') {
reconnect(value as string)
}
})

Permission: storage.collections

MongoDB-inspired document storage. All operations are automatically scoped to the calling extension. Data is stored as JSON documents organized into collections.

Collections can be declared in the manifest under contributes.storage.collections with optional indexes for faster queries.

interface StorageAPI {
// Single document operations
put<T extends object>(collection: string, id: string, data: T): Promise<void>
get<T>(collection: string, id: string): Promise<T | undefined>
delete(collection: string, id: string): Promise<boolean>
// Query operations
find<T>(collection: string, query?: Query, options?: QueryOptions): Promise<T[]>
findOne<T>(collection: string, query: Query): Promise<T | undefined>
count(collection: string, query?: Query): Promise<number>
// Bulk operations
putMany<T extends object>(collection: string, docs: Array<{ id: string; data: T }>): Promise<void>
deleteMany(collection: string, query: Query): Promise<number>
// Collection management
dropCollection(collection: string): Promise<void>
listCollections(): Promise<string[]>
}
MethodDescription
put(collection, id, data)Store or replace a document.
get(collection, id)Retrieve a document by ID. Returns undefined if not found.
delete(collection, id)Delete a document. Returns true if it existed.
MethodDescription
find(collection, query?, options?)Find all documents matching a query. Returns an empty array if none match.
findOne(collection, query)Find the first document matching a query. Returns undefined if none match.
count(collection, query?)Count documents matching a query.
MethodDescription
putMany(collection, docs)Store multiple documents in a single operation. More efficient than calling put in a loop.
deleteMany(collection, query)Delete all documents matching a query. Returns the count of deleted documents.
MethodDescription
dropCollection(collection)Delete an entire collection and all its documents. This cannot be undone.
listCollections()List all collection names owned by this extension.

The Query object supports exact matching and comparison operators:

interface Query {
[field: string]:
| unknown // Exact match
| { $gt?: unknown } // Greater than
| { $gte?: unknown } // Greater than or equal
| { $lt?: unknown } // Less than
| { $lte?: unknown } // Less than or equal
| { $ne?: unknown } // Not equal
| { $in?: unknown[] } // In array
| { $contains?: string } // String contains (case-insensitive)
}
interface QueryOptions {
sort?: { [field: string]: 'asc' | 'desc' }
limit?: number
offset?: number
}

Examples:

// Store a document
await storage.put('users', 'user-123', { name: 'Alice', role: 'admin' })
// Retrieve by ID
const user = await storage.get<User>('users', 'user-123')
// Find with exact match
const admins = await storage.find<User>('users', { role: 'admin' })
// Find with comparison operators
const recent = await storage.find<Event>('events', {
createdAt: { $gt: '2024-01-01' }
}, { sort: { createdAt: 'desc' }, limit: 20 })
// Case-insensitive text search
const matches = await storage.find<Contact>('contacts', {
name: { $contains: 'alice' }
})
// Count documents
const total = await storage.count('users')
const activeCount = await storage.count('users', { status: 'active' })
// Bulk insert
await storage.putMany('logs', [
{ id: 'log-1', data: { level: 'info', message: 'Started' } },
{ id: 'log-2', data: { level: 'info', message: 'Connected' } }
])
// Bulk delete
const removed = await storage.deleteMany('logs', { level: 'debug' })

Permission: secrets.manage

Encrypted key-value storage for sensitive data such as API keys, tokens, and passwords. Values are encrypted at rest. All operations are scoped to the calling extension.

interface SecretsAPI {
set(key: string, value: string): Promise<void>
get(key: string): Promise<string | undefined>
delete(key: string): Promise<boolean>
list(): Promise<string[]>
}
MethodDescription
set(key, value)Store or replace a secret.
get(key)Retrieve a secret value. Returns undefined if not found.
delete(key)Delete a secret. Returns true if it existed.
list()List all secret keys (not values) owned by this extension.

Example:

// Store a secret during setup
await context.secrets!.set('api-key', 'sk-abc123')
// Read it back later (e.g., in a tool execute)
const key = await context.secrets.get('api-key')
if (!key) {
return { success: false, error: 'API key not configured' }
}

Permission: scheduler.register

Schedule one-time or recurring jobs. When a job fires, the registered callback is invoked with a payload and an ExecutionContext.

interface SchedulerAPI {
schedule(job: SchedulerJobRequest): Promise<void>
cancel(jobId: string): Promise<void>
onFire(callback: (payload: SchedulerFirePayload, context: ExecutionContext) => void | Promise<void>): Disposable
}
MethodDescription
schedule(job)Register a new scheduled job.
cancel(jobId)Cancel a previously scheduled job by its ID.
onFire(callback)Register a handler that runs when any of this extension’s jobs fire.
interface SchedulerJobRequest {
id: string
schedule: SchedulerSchedule
payload?: Record<string, unknown>
misfire?: 'run_once' | 'skip'
userId: string
}
FieldDescription
idUnique job identifier.
scheduleWhen the job should fire (see below).
payloadArbitrary data passed to the callback when the job fires.
misfireWhat to do if the job was missed (e.g., the app was closed): 'run_once' fires it once on next startup, 'skip' ignores the missed run.
userIdThe user this job belongs to. Passed through to the callback’s ExecutionContext.
type SchedulerSchedule =
| { type: 'at'; at: string } // One-time
| { type: 'cron'; cron: string; timezone?: string } // Cron
| { type: 'interval'; everyMs: number } // Fixed interval
TypeDescriptionExample
atFire once at a specific ISO 8601 date/time.{ type: 'at', at: '2024-12-25T10:00:00Z' }
cronFire on a cron schedule. Optional timezone (defaults to UTC).{ type: 'cron', cron: '0 */6 * * *', timezone: 'Europe/Stockholm' }
intervalFire at a fixed interval in milliseconds.{ type: 'interval', everyMs: 300_000 } (every 5 minutes)
interface SchedulerFirePayload {
id: string
payload?: Record<string, unknown>
scheduledFor: string // When the job was supposed to fire
firedAt: string // When it actually fired
delayMs: number // Difference between scheduledFor and firedAt
userId: string
}

Example:

// Register the fire handler
context.scheduler?.onFire(async (fire, execContext) => {
context.log.info('Job fired', { jobId: fire.id, delay: fire.delayMs })
if (fire.id === 'check-mail') {
const accounts = await execContext.userStorage.find('accounts', { enabled: true })
for (const account of accounts) {
await checkMail(account, execContext)
}
}
})
// Schedule a recurring job
await context.scheduler?.schedule({
id: 'check-mail',
userId: 'user-1',
schedule: { type: 'interval', everyMs: 5 * 60 * 1000 },
misfire: 'run_once'
})
// Schedule a one-time reminder
await context.scheduler?.schedule({
id: 'reminder-abc',
userId: 'user-1',
schedule: { type: 'at', at: '2024-12-25T10:00:00Z' },
payload: { message: 'Merry Christmas!' }
})

Permission: background.workers

Run long-lived background tasks that survive individual requests. Tasks can be automatically restarted on failure with configurable backoff.

interface BackgroundWorkersAPI {
start(config: BackgroundTaskConfig, callback: BackgroundTaskCallback): Promise<Disposable>
stop(taskId: string): Promise<void>
getStatus(): Promise<BackgroundTaskHealth[]>
}
MethodDescription
start(config, callback)Start a new background task. Returns a Disposable that stops the task when disposed.
stop(taskId)Stop a running task by its ID.
getStatus()Get health status of all background tasks for this extension.
interface BackgroundTaskConfig {
id: string
name: string
userId: string
restartPolicy: BackgroundRestartPolicy
payload?: Record<string, unknown>
}
FieldDescription
idUnique task ID within the extension.
nameHuman-readable name for logging and observability.
userIdUser that owns this task.
restartPolicyControls automatic restart behavior on failure.
payloadOptional data passed to the callback.
interface BackgroundRestartPolicy {
type: 'always' | 'on-failure' | 'never'
maxRestarts?: number // 0 = unlimited (default: 0)
initialDelayMs?: number // Default: 1000
maxDelayMs?: number // Default: 60000
backoffMultiplier?: number // Default: 2
}
type valueBehavior
'always'Restart the task regardless of how it exited.
'on-failure'Restart only if the task threw an error.
'never'Never restart automatically.

Restarts use exponential backoff: the delay starts at initialDelayMs and multiplies by backoffMultiplier after each restart, up to maxDelayMs.

The callback receives a BackgroundTaskContext that extends ExecutionContext with task-specific features:

interface BackgroundTaskContext extends ExecutionContext {
readonly signal: AbortSignal
reportHealth(status: string): void
readonly log: LogAPI
}
PropertyDescription
signalAn AbortSignal that is triggered when the task should stop. Your callback should check this signal regularly and exit gracefully.
reportHealth(status)Report the task’s current status for observability.
logLogger tagged with the task ID.
interface BackgroundTaskHealth {
taskId: string
name: string
userId: string
status: 'pending' | 'running' | 'stopped' | 'failed' | 'restarting'
restartCount: number
lastHealthStatus?: string
lastHealthTime?: string
error?: string
}

Example:

const task = await context.backgroundWorkers!.start({
id: 'imap-idle',
name: 'IMAP IDLE listener',
userId: 'user-1',
restartPolicy: { type: 'on-failure', maxRestarts: 10, initialDelayMs: 2000 }
}, async (ctx) => {
const connection = await connectIMAP(ctx)
try {
while (!ctx.signal.aborted) {
ctx.reportHealth('Waiting for new mail...')
const mail = await connection.idle({ signal: ctx.signal })
if (mail) {
await processMail(mail, ctx)
ctx.reportHealth(`Processed mail: ${mail.subject}`)
}
}
} finally {
await connection.close()
}
})
// Later, stop the task
task.dispose()
// Or check status
const statuses = await context.backgroundWorkers!.getStatus()
for (const s of statuses) {
context.log.info(`Task ${s.name}: ${s.status}, restarts: ${s.restartCount}`)
}

Permission: events.emit

Emit named events that can trigger UI refreshes. UI components and panels can declare a refreshOn property in the manifest to automatically re-render when a specific event is emitted.

interface EventsAPI {
emit(name: string, payload?: Record<string, unknown>): Promise<void>
}
ParameterDescription
nameThe event name. UI components reference this in their refreshOn field.
payloadOptional data attached to the event.

Example:

// After modifying data, tell the UI to refresh
await context.events!.emit('contacts-updated', { count: newContacts.length })
// A panel in the manifest can then use:
// "refreshOn": ["contacts-updated"]

Permission: chat.message.write

Append system-level instructions to the current conversation. This is used by extensions that need to inject context or guidance into the AI’s prompt.

interface ChatAPI {
appendInstruction(message: ChatInstructionMessage): Promise<void>
}
interface ChatInstructionMessage {
text: string
conversationId?: string
userId?: string
}
FieldDescription
textThe instruction text to inject into the conversation.
conversationIdTarget a specific conversation. If omitted, targets the current active conversation.
userIdTarget a specific user’s conversation. If omitted, uses the current user.

Example:

await context.chat!.appendInstruction({
text: 'The user has 3 unread emails from their manager. Mention this if relevant.'
})

Permission: user.profile.read

Read the current user’s profile information. Useful for personalizing tool behavior (e.g., formatting dates for the user’s timezone, addressing them by name).

interface UserAPI {
getProfile(): Promise<UserProfile>
}
interface UserProfile {
firstName?: string
nickname?: string
language?: string // ISO 639-1 code, e.g., "sv", "en"
timezone?: string // IANA timezone, e.g., "Europe/Stockholm"
}

Example:

const profile = await context.user!.getProfile()
const greeting = profile.nickname
? `Hello, ${profile.nickname}!`
: `Hello!`
const tz = profile.timezone || 'UTC'

Many API methods return a Disposable object:

interface Disposable {
dispose(): void
}

Call dispose() to unregister or clean up the resource. This is the primary mechanism for teardown.

Returning from activate:

activate(context) {
const toolDisposable = context.tools?.register(myTool)
const settingsDisposable = context.settings?.onChange(handleChange)
// Return a composite disposable
return {
dispose() {
toolDisposable?.dispose()
settingsDisposable?.dispose()
}
}
}

If activate() returns a Disposable, Stina calls dispose() automatically when the extension is deactivated. Alternatively, use the deactivate() method on the extension module for cleanup logic.


Tool names and descriptions can be localized using a LocalizedString:

type LocalizedString = string | Record<string, string>

When a plain string is provided, it is used as-is. When an object is provided, Stina selects the appropriate translation based on the user’s language setting, falling back to en if the user’s language is not available.

Example:

context.tools?.register({
id: 'get-weather',
name: { en: 'Get Weather', sv: 'Hamta vader', de: 'Wetter abrufen' },
description: { en: 'Fetch current weather for a location', sv: 'Hamta aktuellt vader for en plats' },
async execute(params) {
// ...
}
})

Each API requires a specific permission declared in the manifest’s permissions array. Here is the complete list:

PermissionUnlocks
network:*NetworkAPI (all hosts)
network:<domain>NetworkAPI (specific domain only)
storage.collectionsStorageAPI
secrets.manageSecretsAPI
user.profile.readUserAPI
chat.message.writeChatAPI
provider.registerProvidersAPI
tools.registerToolsAPI
actions.registerActionsAPI
settings.registerSettingsAPI
events.emitEventsAPI
scheduler.registerSchedulerAPI
background.workersBackgroundWorkersAPI

Additional permissions exist for capabilities not covered in this API reference:

PermissionPurpose
commands.registerRegister keyboard commands
panels.registerRegister UI panels
chat.history.readRead conversation history
chat.current.readRead the current conversation
user.location.readRead user location data
files.readRead files on disk
files.writeWrite files on disk
clipboard.readRead clipboard contents
clipboard.writeWrite to clipboard

Only request the permissions your extension actually needs. Users can see the full list of requested permissions before installing an extension.