Building
Model providers
Make your vendor's chat or decision models selectable on AgentParley, like OpenAI or Anthropic.
A model-provider plugin makes a vendor selectable under AI Providers → Add provider, next to the built-in ones. The user adds it with its own settings (typically their API key), adds models under it, and picks those models for agents — exactly like a native provider. Your plugin translates between AgentParley's request format and the vendor's API.
A provider plugin can host any combination of four groups:
| Group | Handlers | What the user gets |
|---|---|---|
| Chat | complete |
models an agent can think with |
| Decision ("System One") | noul + choose + score — all three or none |
fast typed answers for decide and host.decide |
| Voice | speak + voices — both or neither |
companion voices — see Voice and speech-to-text |
| Speech-to-text | transcribe |
companion listening — see Voice and speech-to-text |
This page covers chat and decision models.
Declare the provider
// infrastructure/plugins/keyword-decider/index.ts
globalThis.manifest = {
modelProvider: { kindId: "keyword-decider", displayName: "Keyword Decider (test)" },
settings: [
{
key: "simulateFailure",
title: "Simulate failure",
type: "enum",
options: ["none", "keyRejected", "unavailable"],
default: "none",
},
],
};
manifest.modelProvider is required as soon as you assign any modelProvider.* handler (see
Manifest). kindId is your provider's permanent id; displayName is shown as
"Keyword Decider (test) from plugin".
Settings belong to the provider the user added, not to the install. When a user adds your provider they fill
in your settings form; host.settings.get / getSecret in a provider handler read that provider's configured
values, not the installation's.
Reporting vendor failures — tools.fail
Every provider handler receives tools with one helper:
tools.fail(kind, message): ModelProviderFailure
kind |
Means | User sees |
|---|---|---|
keyRejected |
bad or revoked key (401/403) | the red provider-failure banner |
noCredit |
out of credit (402) | the red banner |
modelNotFound |
the saved model id doesn't exist | the red banner |
requestRejected |
the vendor refused this request (400/422) | the red banner |
rateLimited |
429 | no banner — transient |
unavailable |
5xx, down | no banner — transient |
Return the value — return tools.fail("keyRejected", "…") — instead of throwing. A thrown error or a timeout is
treated as unavailable. The OpenAI Voice plugin maps HTTP status codes like this:
// infrastructure/plugins/openai-voice/index.ts
function mapFailure(status: number, tools: ModelProviderTools): ModelProviderFailure {
if (status === 401 || status === 403) return tools.fail("keyRejected", "OpenAI rejected the API key");
if (status === 402) return tools.fail("noCredit", "OpenAI account is out of credit");
if (status === 429) return tools.fail("rateLimited", "OpenAI rate limit exceeded");
if (status >= 500) return tools.fail("unavailable", `OpenAI returned ${status}`);
return tools.fail("requestRejected", `OpenAI returned ${status}`);
}
Chat models — complete
Written for these docs against the SDK types — no shipped plugin implements
completeyet.
modelProvider.complete(request: CompletionRequest, tools): CompletionResponse | ModelProviderFailure
The request is AgentParley's normalized chat request:
interface CompletionRequest {
schemaVersion: number;
model: string; // the model identifier the user saved
messages: {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
toolCalls?: { id: string; name: string; argumentsJson: string }[]; // on assistant messages
toolCallId?: string; // on tool results
media?: { type: "image" | "audio"; url: string; contentType: string }[];
thinking?: { text: string | null; replayJson: string | null };
}[];
tools: { name: string; description: string; parametersJsonSchema: string }[];
params: { temperature: number | null; maxTokens: number | null; topP: number | null;
stop: string[] | null; thinking: string };
}
Return the assistant turn and the token usage:
interface CompletionResponse {
schemaVersion: 1;
assistant: { text: string | null; toolCalls: { id: string; name: string; argumentsJson: string }[];
thinking?: { text: string | null; replayJson: string | null } };
usage: { inputTokens: number; outputTokens: number; cachedTokens: number };
stopReason: "stop" | "tool_calls" | "length" | "content_filter" | "error";
}
A minimal adapter for an OpenAI-style vendor:
globalThis.manifest = {
egress: ["api.example-llm.com"],
modelProvider: { kindId: "example-llm", displayName: "Example LLM", mediaSupport: ["image"] },
settings: [{ key: "apiKey", title: "API key", type: "secret", required: true }],
};
globalThis.modelProvider.complete = async (request, tools) => {
const apiKey = await host.settings.getSecret("apiKey");
const response = await host.fetch({
method: "POST",
url: "https://api.example-llm.com/v1/chat/completions",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: request.model,
messages: request.messages.map((m) => ({ role: m.role, content: m.content, tool_call_id: m.toolCallId })),
tools: request.tools.map((t) => ({
type: "function",
function: { name: t.name, description: t.description, parameters: JSON.parse(t.parametersJsonSchema) },
})),
temperature: request.params.temperature ?? undefined,
max_tokens: request.params.maxTokens ?? undefined,
}),
});
if (response.status === 401) return tools.fail("keyRejected", "Example LLM rejected the API key");
if (response.status !== 200) return tools.fail("unavailable", `Example LLM returned ${response.status}`);
const body = JSON.parse(response.body);
const choice = body.choices[0];
const toolCalls = (choice.message.tool_calls ?? []).map((c: any) => ({
id: c.id, name: c.function.name, argumentsJson: c.function.arguments,
}));
return {
schemaVersion: 1,
assistant: { text: choice.message.content, toolCalls },
usage: { inputTokens: body.usage.prompt_tokens, outputTokens: body.usage.completion_tokens, cachedTokens: 0 },
stopReason: toolCalls.length > 0 ? "tool_calls" : "stop",
};
};
Notes:
- No credential crosses the request. Read your key with
host.settings.getSecret. mediaSupportin the manifest says which media your models accept. The platform strips unsupported media frommessagesbefore calling you, the same as for native providers.- Usage is what the user pays. You report tokens; the platform prices them at the per-token rates the user entered for the model. Plugins never report a cost.
completeis not streamed: the reply appears when your handler returns. It gets the ordinary 30 s wall clock, and eachhost.fetchinside it times out after 10 s — a vendor reply that takes longer fails asunavailable.- Your handler must return an object with
assistantandusage, or the call fails as a bad request.
Decision models — noul, choose, score
Decision models answer typed questions about a piece of state, fast and with calibrated numbers. Agents use them
through the built-in decide tool; plugins through host.decide; companions to pick gestures. Your plugin can
host them.
All three handlers take the same request shape — one call answers every question, reading state once:
request: {
model: string;
state: string | Record<string, unknown> | unknown[];
questions: NoulQuestion[] | ChoiceQuestion[] | ScoreQuestion[];
}
returns: { answers: …[]; inputTokens: number } // same order and count as `questions`
| Handler | Question | Answer |
|---|---|---|
noul — how likely is this true? |
{ instructions, trueMeans?, falseMeans? } |
{ probability } in 0–1 |
choose — pick one option |
{ instructions, options: { [key]: description | null } } |
{ choice, confidence?, probabilities? } — choice must be one of the keys |
score — place on a scale |
{ instructions, levels: string[] } (low → high) |
{ score, confidence?, probabilities? } — score in 0…levels.length − 1, may be fractional |
// infrastructure/plugins/keyword-decider/index.ts (abridged)
globalThis.modelProvider = {
noul(request, tools) {
const failure = simulatedFailure(tools);
if (failure) return failure;
const text = stateText(request.state);
const answers = request.questions.map((question) => {
const positive = overlap(text, question.trueMeans ?? question.instructions);
const negative = overlap(text, question.falseMeans ?? "");
const sign = positive === negative ? 0 : positive > negative ? 1 : -1;
return { probability: 0.5 + 0.4 * sign };
});
return { answers, inputTokens: Math.ceil(text.length / 4) };
},
choose(request, tools) { /* … */ },
score(request, tools) { /* … */ },
};
- Implement all three or none — the platform may call any of them, and adding a model runs a test
noul. - 10 s wall clock per call.
inputTokensis what the user is charged for, at their rate.
Rules for provider plugins
- No agent powers. A provider translates a vendor's API; it doesn't act as an agent. Provider handlers get no
host.session,host.agents,host.llmorhost.decide, and publishing a provider plugin that callshost.session.message.post,todowrites,subagent.spawn,peer.send,notify.scheduleorhost.agents.create/update/deletefails. Account reads such ashost.models.list()are allowed. - No silent fallback. If your model fails, the agent's turn fails visibly (with the banner for the four "hard" kinds). The platform never quietly switches the user to another model.
How a user sets it up
- Install the plugin.
- AI Providers → Add provider → "Your name from plugin", fill in your settings (their API key).
- Add a model under it, entering the identifier your vendor uses (it reaches you as
request.model), with its per-token prices. - Pick that model on an agent — or as the account's decision model for decision providers.