Add a provider
Start with the protocol
Does the endpoint speak the Anthropic API or OpenAI Responses? For your own installation, you need no plugin: use the form or a local backend JSON file in Add a provider. The corresponding CLI must be installed. OpenAI Chat Completions alone is not the Responses protocol.
To share endpoint and model configuration with other users, distribute a
declarative provider pack. Add host code only when you need dynamic
credentials or another CLI protocol. All paths use backends:register, since
they choose where mission prompts are sent. Normal plugin installation,
permission, signature and licence requirements still apply; see
Testing and Publishing.
Share a declarative pack
A pack is an ESM npm package without main. Put providers in timon.backends.
Here is a complete package.json; replace the example endpoint and model ID
with values supported by your provider:
{
"name": "@acme/timon-provider",
"version": "0.1.0",
"type": "module",
"timon": {
"id": "acme-provider",
"apiVersion": 2,
"kind": "behavior",
"label": "Acme provider",
"permissions": ["backends:register"],
"settings": [
{ "key": "apiKey", "type": "secret", "label": "Provider API key" }
],
"backends": [{
"id": "acme-responses",
"dialect": "codex",
"label": "Acme Responses",
"env": { "ACME_API_KEY": { "setting": "apiKey" } },
"providerConfig": {
"id": "acme",
"name": "Acme",
"baseUrl": "https://responses.example.test/v1",
"envKey": "ACME_API_KEY",
"wireApi": "responses"
},
"defaultModel": "example-model",
"models": [{ "slug": "example-model", "label": "Example model" }]
}],
"license": { "required": false }
}
}
Install the prepared package with timon plugin add ./my-provider, then enter
the key in its plugin settings. No JavaScript is loaded, even if an index.js
file exists. API 2 is the original backend-facade floor; declarative packs need
a Timon release supporting timon.backends (introduced on 18 September 2026).
For an Anthropic-compatible endpoint, use dialect: "claude" with
ANTHROPIC_BASE_URL as a literal URL and ANTHROPIC_AUTH_TOKEN as the setting
reference; omit providerConfig. The cookbook
contains both complete profiles in one pack.
{ "setting": "apiKey" } references only a type: "secret" setting declared
by this plugin. Its current value is read at each mission launch and its
availability is checked when the catalogue is consulted. Saving, replacing or
clearing it takes effect without reloading the plugin. A manifest secret
default is not used. Keep actual keys out of the package.
Alternatively, { "secret": "ACME_API_KEY" } references a vault entry, subject
to the mission's normal secret grants. See Secrets. TypeScript
authors can import SettingRef, SecretRef and LocalBackendFile from the SDK.
A setting reference is valid only in a plugin pack, not a standalone local file.
What is refused
Entries use the same strict schema as local backend files:
maintogether withtimon.backends, or missingbackends:register: the manifest is refused. Packs do not mix executable host code with declarations.cmd,requiredBinary,envFrom,requiredEnv, and unknown fields: data cannot select a binary or read inherited daemon credentials.- An undeclared setting or one not typed
secret: a pack cannot read arbitrary settings or another plugin's key. - Environment names outside provider-data naming rules, or reserved runtime,
loader and home variables: declarations must not alter process execution.
Names must use uppercase snake case with a provider prefix and end in
_API_KEY,_AUTH_TOKEN,_TOKEN,_BASE_URL,_API_BASE,_API_VERSION,_ORGANIZATION,_PROJECT,_REGIONor_MODEL; reserved names remain forbidden even with these suffixes. This also applies toproviderConfig.envKey. - A duplicate backend ID: a pack cannot replace a built-in, local or plugin profile. Use a distinct lowercase letters/digits/dashes ID, at most 40 characters.
An invalid entry stays out of the registry with its reason in plugin status; valid neighbours still load. A missing key or unavailable dialect keeps a registered profile inactive. Disabling, removing or updating the pack removes its registrations.
Add code for dynamic credentials
Use a host entry point when a token must be refreshed. Declare main in the
package, omit timon.backends, and keep backends:register, a secret setting,
and the settings permission. For example, lib/index.ts:
import type { PluginContext, RegisteredProfile, EnvResolver } from '@timon-ai/sdk';
export function apply(ctx: PluginContext) {
const env: EnvResolver = () => {
const apiKey = ctx.settings.get('apiKey');
if (typeof apiKey !== 'string' || !apiKey) throw new Error('Missing provider key');
return { ANTHROPIC_AUTH_TOKEN: apiKey };
};
const profile: RegisteredProfile = {
id: 'acme-dynamic',
dialect: 'claude',
label: 'Acme dynamic credentials',
env: async () => ({
...await env(),
ANTHROPIC_BASE_URL: 'https://anthropic.example.test',
}),
defaultModel: 'example-model',
models: [{ slug: 'example-model', label: 'Example model' }],
};
ctx.backends.registerProfile(profile);
}
Compile TypeScript to JavaScript and point main at that output before
packaging. Obtain @timon-ai/sdk with the developer distribution; public npm
availability is not assumed. The code example shows
an entire JavaScript host template.
RegisteredProfile requires id, dialect, label, models (possibly an
empty array) and defaultModel (possibly null). Optional display fields
include vendor, brand and custom; a Responses profile also supplies
providerConfig. The type reference
links to all inherited profile fields; envFrom, requiredEnv and the base
env shape are specifically excluded from code profiles.
The env resolver is called at every mission launch, and also for
availability probes. It may return a promise. Refresh a short-lived token
inside that function, reading its current inputs from settings or ctx.secrets
(with the corresponding permission and grants). Make repeated or concurrent
calls safe; a probe is not evidence that a mission will launch. Do not capture
a key once in apply, and do not conditionally skip registration when it is absent.
Throwing or rejecting makes the profile inactive. The daemon never relays the
error's message; keep secrets out of errors and your own logs anyway. The next
successful resolution can restore availability. Code profiles accept literal
non-secret strings or an EnvResolver, not { secret } / { setting }
objects, envFrom or requiredEnv. Read credentials through the plugin context
inside the function instead.
Add a dialect only for another CLI protocol
A dialect describes how to speak to a CLI. It is justified when the CLI's launch arguments, input or output protocol differ from existing dialects; a different API endpoint or model list alone does not require one.
Import Dialect from the SDK. Register it with
ctx.backends.registerDialect('acme-cli', dialect), then register a profile
whose dialect is acme-cli. The returned disposers allow early cleanup;
registrations are also removed automatically when the plugin unloads.
Set dialect.id to the identifier passed to registerDialect. For backward
compatibility, existing JavaScript plugins without id still register and
work, but emit a deprecation warning. A mismatched id also emits a warning
while registration continues under the identifier passed to registerDialect;
Timon does not rewrite the dialect object. Omission is deprecated; new plugins
should always supply matching identifiers.
The daemon launches the process. A dialect returns a spawn description;
it does not start its own process. A plugin-selected executable is checked
against the operator's pluginPolicy.exec.allow. Reusing a built-in dialect's
binary does not add an executable to that policy.
The required Dialect members are:
| Member | Contract |
|---|---|
id, defaultCmd | Dialect identifier and default executable. |
persistentProcess, finishMissionOnExit | Process lifetime and whether exit ends the mission. A one-shot process must leave the mission alive. |
sessionResumable, sessionStore | Resume support and global or per-mission session storage. |
permissionModel | mcp-bridge, sandbox or none, matching the CLI's actual tool controls. |
rateLimitReporting | structured, textual or none, matching observable limit signals. |
hostedConnectors | Whether the CLI supplies hosted account connectors. |
buildSpawn(opts, profile) | Async function returning cmd, args, initialStdin and optional env/cwd. Use the resolved profile environment. |
writeUserInput(proc, text, images?) | Write another turn to persistent stdin; a one-shot implementation does nothing. |
parseLine(line) | Return normalized events; unusable lines produce [], never an exception. |
extractSessionId(event) | Return a non-empty session ID from system:init, otherwise null. |
Optional hooks include usage reporting, trace spans, model switching, session
error detection and disposal; see Dialect.
Set maxPromptBytes if the CLI has a prompt size limit. The
echo template illustrates the protocol, not a working
AI provider or the operating system's echo command.
Test your dialect without the product repository
The product's conformance suite is internal and is not shipped in the SDK. Implement these checks in your own test runner with recorded CLI JSON-lines fixtures, a temporary directory, and fake stdin; no real API key is needed:
- Verify capability fields and enum values. Persistent processes and global
session stores must declare
sessionResumable: true. Build a fresh spawn and assert the selected command, string arguments, working directory and resolved env. Confirm the model, system prompt and user message reach argv, stdin or the generated CLI files. Exercise the advertised prompt limit without oversized single arguments (in particular Linux's 131,072-byte argument ceiling), including multibyte text. If nomaxPromptBytesis declared, the prompt must not travel in argv. - Build a resumed spawn and verify the session ID is passed, while fresh
spawns do not resume. Keep the
initialStdinstrategy consistent across turns. - A persistent process must receive initial stdin and subsequent turns through
writeUserInput. A one-shot process must write nothing on subsequent stdin and declarefinishMissionOnExit: false. - Parse a complete fixture into JSON-serializable
system,assistant,user,resultorassistant_deltaevents. Exactly onesystem:initopens the turn; if it names a backend, it names the profile. Results carry booleanis_errorandsubtype: "success"or"error". Test both outcomes. - Empty lines, whitespace, banners, malformed JSON and
nullreturn[]. Only the init event yields a session ID; other events, non-init system events and empty IDs returnnull. - If disposal exists, calling it for a mission with no on-disk state is safe. Separately exercise unload, missing credentials and executable-policy refusal on a test installation before distributing the plugin.