Behaviors
A Behavior is a typed, versioned AI contract your application calls by a stable key — what one is made of, how a version becomes live, and how to run it.
A Behavior is one AI capability, defined and versioned in Salyro rather than in your application. Your code sends the input the capability takes and receives the output it declares; which model answers, what it was asked, and with which parameters are things the Behavior holds.
That is the difference from the rest of the API in one sentence. A request to
/v1/chat/completions says which model to use and carries the whole prompt. A
request to a Behavior says which capability to invoke and carries only the data
that capability needs.
Behaviors and the Raw Gateway
Both live at the same base URL, take the same Salyro API key and resolve the same gateway. Neither replaces the other.
| Raw Gateway | Behavior | |
|---|---|---|
| Endpoints | /v1/chat/completions, /v1/responses, /v1/models | /v1/behaviors/{key}/run |
| Wire format | OpenAI-compatible | Salyro's own — the Behavior's declared input and output |
| Who chooses the model | Your code, in every request | The Behavior's active version |
| Who owns the prompt | Your code | The Behavior |
| Changing the prompt | A deploy of your application | Publishing and activating a version in Salyro |
| Client | Any OpenAI-compatible SDK | An HTTP call — see below |
Which one to use
Use the Raw Gateway when your application is the thing that decides what to say: an agent loop you orchestrate, tool calling you execute, a prompt assembled from context only your code has, or an existing OpenAI integration you are pointing at Salyro. It is also the only surface that speaks a format an SDK already parses.
Use a Behavior when the AI part of a feature is stable enough to name — "classify this ticket", "extract these invoice fields", "summarise this call" — and you would rather change how it works without shipping your application again. The gain is that the prompt, the model and the parameters stop being constants in your codebase, and every change to them is a numbered version with an author and a time.
Mixing them in one application is ordinary. They are two ways to spend the same provider credentials through the same gateway.
What a Behavior is made of
Five parts, and they are one unit: they are versioned together, validated together and executed together.
- Input Contract — the fields your application may send, each with a type, whether it is required, and an optional default. It is the single source of truth for what inputs exist.
- Prompt Composition — the messages sent to the model, with the declared input placed into them explicitly. A field that exists in the contract but is never placed is never sent.
- Model & Parameters — which model answers, and the parameters it answers with.
- Output Contract — either Free Text or a structured object with a JSON schema. A structured answer is validated against that schema before you receive it.
- Execution — the run itself, through the same routing, catalogue,
credential and capability path every
/v1request already takes.
A Behavior belongs to one gateway, exactly like a provider credential or an API key. There is no account-level Behavior shared between gateways, for the same reason there is no shared credential — see Gateways.
There is no hidden prompt
Data reaches the model if, and only if, the Prompt Composition places it there. A field declared in the Input Contract and never placed is not sent under any condition, and nothing is appended to your prompt on the way out.
The key is the part your code writes down
A Behavior is addressed by a key — a short, lower-case identifier such as
invoice-extractor — unique within its gateway.
POST /v1/behaviors/invoice-extractor/run
The key is set when the Behavior is created and never changes afterwards. That is a product decision rather than a limitation: once the key is in your source control, renaming it would be a breaking change to your application made on your behalf. The display name and the description are editable; the key is not, and there is no rename, no alias and no redirect from an old key to a new one.
The gateway is not in the URL. It comes from the API key, so a request has nowhere to name a Behavior outside the gateway its key belongs to.
Draft, version, activation
A Behavior has one editable Draft and any number of published Versions.
Draft
The single working copy, edited in the Salyro dashboard. Your application cannot call it — a draft is not an address.
Publish
Freezes the current definition as a numbered version. Versions start at 1 and count up within one Behavior. A published version is never edited; a change is a new version.
Activate
Makes one published version the one that answers
run. Rolling back is not a separate operation — it is activating an earlier version.
Every run — including a test run from the dashboard — is locked to an immutable definition before the model is called, so there is no execution whose exact prompt, model, parameters and contracts cannot be recovered afterwards. An activation that commits while a request is in flight does not change the version that request is already running.
POST /v1/behaviors/{key}/run
One endpoint, on the same base URL as the rest of the API:
https://api.salyro.com/v1
Authentication
The same Salyro API key as every other /v1 endpoint, as a bearer token, and
the same per-key rate limit:
Authorization: Bearer sk-sly-...
Authentication covers where the key comes from and what a rejected request looks like.
Request
| Field | Required | What it does |
|---|---|---|
input | Yes | A JSON object, validated against the Behavior's Input Contract. |
version | No | Pin a published version instead of resolving the active one. |
stream | No | true to receive a Free Text answer as server-sent events. |
There is no model, no messages and no parameters. Those are the Behavior's,
and a request cannot override them.
Response
| Field | What it is |
|---|---|
id | Salyro's id for this execution. The same value as the x-request-id header. |
behavior | The key that answered. |
version | The published version that ran — always a number on this endpoint. |
output | { "kind": "text", "text": … } or { "kind": "structured", "value": … }. |
usage | inputTokens, outputTokens, totalTokens. |
output is discriminated on kind so that a Free Text Behavior returning an
empty answer stays distinguishable from a structured one returning nothing.
A run here always resolves a published version — the active one, or the one
you pinned. A draft is not addressable from this endpoint, so there is no
response in which version is absent.
Cost is deliberately not in the response. It is computed from the pricing catalogue after the fact and can be genuinely unknown, and a response carrying a number would have to invent one for that case — see Usage & costs.
A request and its answer
curl https://api.salyro.com/v1/behaviors/invoice-extractor/run \
-H "Authorization: Bearer $SALYRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": { "question": "why" }
}'
{
"id": "req_0123456789abcdef0123456789abcdef",
"behavior": "invoice-extractor",
"version": 1,
"output": { "kind": "text", "text": "the answer" },
"usage": { "inputTokens": 7, "outputTokens": 3, "totalTokens": 10 }
}
The five response fields are the whole contract. Salyro's internal identifier for the frozen definition is not among them: it is metadata on the log row rather than something your code should branch on.
Pinning a version
Leave version out and the active version answers, which is the ordinary case
and the entire reason the control plane exists.
{ "input": { "question": "why" }, "version": 3 }
A pin names a published version — never a draft. A version this Behavior has
never published is a 404; it is never quietly served by the active version
instead, because the point of a pin is that it names one thing.
Streaming
Streaming is supported for a Free Text Output Contract only.
{ "input": { "question": "why" }, "stream": true }
The answer arrives as server-sent events. Each frame carries the event name
twice — on its event: line, and as type inside the JSON on its data: line
— so you can switch on whichever your client gives you.
Read those frames from the response body, with fetch or your language's HTTP
client. A browser's native EventSource cannot call this endpoint at all: it
only ever issues a GET, with no request body and no way to set
Authorization, and this is a POST that needs both. An
EventSource-compatible library that accepts a method, headers and a body
works.
| Event | Carries |
|---|---|
response.started | id, behavior, and the version locked for this run. |
output_text.delta | delta — one fragment of the answer, and nothing else. |
response.completed | id and usage. Exactly one, at the end of a good stream. |
error | Salyro's error object. Nothing follows it. |
event: response.started
data: {"type":"response.started","id":"req_0123456789abcdef0123456789abcdef","behavior":"invoice-extractor","version":1}
event: output_text.delta
data: {"type":"output_text.delta","delta":"the "}
event: output_text.delta
data: {"type":"output_text.delta","delta":"answer"}
event: response.completed
data: {"type":"response.completed","id":"req_0123456789abcdef0123456789abcdef","usage":{"inputTokens":7,"outputTokens":3,"totalTokens":10}}
Three properties of that stream are promises rather than incidental:
- It is Salyro's own format, not OpenAI's. There are no
chat.completion.chunkobjects, nochoices, noindexand nofinish_reason— this surface does not have them. - There is no
[DONE]sentinel. Completion is an event with a body.response.completedis the end of the stream. response.completeddoes not repeat the answer. It carries the id and the usage; the full text is the deltas you have already received, concatenated.
A failure after the stream has opened sends one error event and closes the
connection without a response.completed. That is what makes a truncated
answer detectable: a stream that ends with no completion event did not finish.
Errors
The envelope is the one the rest of the API uses — message, type, code,
param — with its own codes:
{
"error": {
"message": "…",
"type": "invalid_request_error",
"code": "behavior_input_invalid",
"param": "/question"
}
}
| Status | code | What happened |
|---|---|---|
400 | behavior_input_invalid | input violates the Input Contract. param is a pointer to the field. |
400 | behavior_streaming_unsupported | stream: true on a structured Output Contract. |
404 | behavior_not_found | No such key in this gateway. |
404 | behavior_version_not_found | A pinned version this Behavior has never published. |
409 | behavior_not_activated | The Behavior exists but no version has been activated yet. |
502 | behavior_output_invalid | The model answered with something the Output Contract refuses. |
The usual 401 for a missing or revoked key and 429 for the key's rate limit
apply here exactly as they do on the Raw Gateway — see Errors.
Two of these are worth reading twice:
behavior_input_invalidis raised before a model is called, so a request that fails it costs nothing.paramis a JSON pointer into yourinputdocument —/question,/customer/tier— rather than a field name, so a nested field is named exactly.- A Behavior that is archived, that belongs to another tenant, or that never
existed all answer
behavior_not_foundidentically. Onlybehavior_not_activatedis distinguishable, because reaching it already required holding the gateway's key and naming a Behavior inside it — and it is the one of these you can act on, by publishing and activating a version.
Headers
| Header | Direction | What it is |
|---|---|---|
Authorization | Request | Bearer <SALYRO_API_KEY>. Required. |
Content-Type | Request | application/json. |
X-Client-Request-Id | Request | Optional. Your own id, for correlating with your logs. |
x-request-id | Response | Salyro's id for this run. On every response. |
x-request-id is present on failures as well as successes, and it is the same
value as id in a successful response body. It is what ties an error your code
received to the execution that produced it, so capture it on every call.
A run is recorded like any other request — tokens, latency and cost — and every Behavior carries its own execution history in the dashboard, showing which version answered.
An OpenAI SDK does not call this endpoint
The OpenAI SDKs speak the Chat Completions and Responses contracts. This
endpoint has neither shape: its request is the Behavior's declared input and its
response is the Behavior's declared output, so there is no OpenAI request or
response for it to be compatible with. client.chat.completions.create cannot
reach it, and there is no client option that makes it.
That is a boundary rather than a gap. Compatibility lives in the Raw Gateway, which is untouched and is where an SDK belongs; this is where Salyro's own contract is expressed. There is no Salyro SDK — a Behavior run is one HTTP request with a JSON body:
curl https://api.salyro.com/v1/behaviors/invoice-extractor/run \
-H "Authorization: Bearer $SALYRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "input": { "question": "why" } }'
const response = await fetch('https://api.salyro.com/v1/behaviors/invoice-extractor/run', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALYRO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ input: { question: 'why' } }),
});
const result = await response.json();
console.log(result.output);
import os
import requests
response = requests.post(
"https://api.salyro.com/v1/behaviors/invoice-extractor/run",
headers={"Authorization": f"Bearer {os.environ['SALYRO_API_KEY']}"},
json={"input": {"question": "why"}},
)
print(response.json()["output"])
If your application is already an OpenAI client and you want it to stay one, keep using the Public API. Behaviors are the surface for the code that would rather not hold a prompt.
