Skip to content
Salyro
Core concepts

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 GatewayBehavior
Endpoints/v1/chat/completions, /v1/responses, /v1/models/v1/behaviors/{key}/run
Wire formatOpenAI-compatibleSalyro's own — the Behavior's declared input and output
Who chooses the modelYour code, in every requestThe Behavior's active version
Who owns the promptYour codeThe Behavior
Changing the promptA deploy of your applicationPublishing and activating a version in Salyro
ClientAny OpenAI-compatible SDKAn 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 /v1 request 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.

Text
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.

  1. Draft

    The single working copy, edited in the Salyro dashboard. Your application cannot call it — a draft is not an address.

  2. 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.

  3. 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:

Text
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:

HTTP
Authorization: Bearer sk-sly-...

Authentication covers where the key comes from and what a rejected request looks like.

Request

FieldRequiredWhat it does
inputYesA JSON object, validated against the Behavior's Input Contract.
versionNoPin a published version instead of resolving the active one.
streamNotrue 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

FieldWhat it is
idSalyro's id for this execution. The same value as the x-request-id header.
behaviorThe key that answered.
versionThe published version that ran — always a number on this endpoint.
output{ "kind": "text", "text": … } or { "kind": "structured", "value": … }.
usageinputTokens, 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

Shell
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" }
  }'
JSON
{
  "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.

JSON
{ "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.

JSON
{ "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.

EventCarries
response.startedid, behavior, and the version locked for this run.
output_text.deltadelta — one fragment of the answer, and nothing else.
response.completedid and usage. Exactly one, at the end of a good stream.
errorSalyro's error object. Nothing follows it.
Text
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.chunk objects, no choices, no index and no finish_reason — this surface does not have them.
  • There is no [DONE] sentinel. Completion is an event with a body. response.completed is the end of the stream.
  • response.completed does 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:

JSON
{
  "error": {
    "message": "…",
    "type": "invalid_request_error",
    "code": "behavior_input_invalid",
    "param": "/question"
  }
}
StatuscodeWhat happened
400behavior_input_invalidinput violates the Input Contract. param is a pointer to the field.
400behavior_streaming_unsupportedstream: true on a structured Output Contract.
404behavior_not_foundNo such key in this gateway.
404behavior_version_not_foundA pinned version this Behavior has never published.
409behavior_not_activatedThe Behavior exists but no version has been activated yet.
502behavior_output_invalidThe 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_invalid is raised before a model is called, so a request that fails it costs nothing. param is a JSON pointer into your input document — /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_found identically. Only behavior_not_activated is 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

HeaderDirectionWhat it is
AuthorizationRequestBearer <SALYRO_API_KEY>. Required.
Content-TypeRequestapplication/json.
X-Client-Request-IdRequestOptional. Your own id, for correlating with your logs.
x-request-idResponseSalyro'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:

Shell
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" } }'

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.