The decide request
The complete model of POST /v2/decide: the JSON request, the JSON response, headers, media rules, and limits. The format is director/v2.
The call
POST https://director.eposyne.com/v2/decide
Authorization: Bearer epd_...
Content-Type: application/jsonThe body is one JSON object, the decision request. Its first member, schema, names the format: eposyne.director.decision-request/v2. Director takes application/json and nothing else, and answers application/json; charset=utf-8 on success and on error. There is no XML form. See Media rules.
Request headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer followed by your Director API key. |
Content-Type | Yes | application/json. A charset parameter is allowed and must be utf-8. |
Accept | No | Leave it out, or make sure one entry admits application/json (*/* and application/* do). Otherwise the call is a 406. |
X-Eposyne-Plugin | No | A label for your client in the form name/version, for example my-game-server/1.4.0. It appears in logs and usage records. It never changes how Director behaves. |
Query parameters
| Parameter | Meaning |
|---|---|
provider and model | Choose the model for this request. Send both or neither. See Models and usage. |
max_tokens | The output-token cap for this call. See Models and usage. |
rationale | true to get a short reason with every change. See Reasons per change. |
world, timeline, and after_seq | The stream position the decision follows. Send all three or none. See below. |
player_id | Whose Plots verdicts the reply carries. Only with a stream position, else 422. Without it, the player of the event at after_seq. See The Plots verdict. |
The stream position parameters tie a decision to the events you sent to POST /v2/events. They are not needed for plain decisions. With a position, Director first processes every event up to after_seq (409 if it cannot within 10 seconds, with nothing charged), then lets Plots and Memory take part in the decision. The reply then carries a plots member when Plots is on. See The Plots verdict and Memory in a decision.
The request document
{
"schema": "eposyne.director.decision-request/v2",
"ask": "Should the guards pursue the fleeing thief or hold position to protect the dignitary?",
"variables": {
"guard_stance": "patrol",
"guard_morale": 50,
"thief_distance": "50m",
"dignitary_threat": "low",
"reinforcements_available": true
},
"mutable": [
{"name": "guard_stance", "allowed_values": ["patrol", "pursue", "hold", "alert"]},
{"name": "guard_morale", "range": {"min": 0, "max": 100}},
{"name": "dignitary_threat", "allowed_values": ["low", "medium", "high"]}
]
}The members
| Member | Required | Meaning |
|---|---|---|
schema | Yes | Exactly eposyne.director.decision-request/v2. |
ask | Yes | The question, as plain text. One decision per request works best. Angle brackets and every other character are ordinary text. |
variables | Yes | The read-only snapshot of game state: an object whose keys are variable names and whose values are JSON scalars (string, number, boolean, or null). Up to 1,024 entries. |
mutable | Yes | The variables the model may suggest a change for. An array of entries, each with a name and exactly one rule: allowed_values, free_text, range, or length. Up to 1,024 entries. |
operations | No | The engine functions the model may propose calling. See Operations and calls. |
validation | No | true turns on a second check for generated text. See Validation. |
metadata | No | An object of up to 64 scalars that Director records with the trace and echoes in the response. The model never sees it. |
A member not in this table is refused with 422 schema_violation. Variable names start with a letter or underscore and are at most 128 characters of letters, digits, _, ., : and -.
Values keep their type
A value in variables is sent with the JSON type you mean: 50 is a number, "50" is a string, true is a boolean. Director never converts. The rule you declare for a mutable variable must fit the type of its current value: a range on a variable whose value is the string "50" is refused with 422 value_type, and the variable member of the error names it. A proposed change keeps the type too, so your engine can apply it without parsing.
The rules
| Rule | Meaning |
|---|---|
allowed_values | A closed list of strings, numbers, or booleans. The proposed value must equal one entry, type included: "1" does not match 1, and Hold does not match hold. |
free_text | true. Any string is allowed. Use it for generated content such as dialogue. The model is asked to always fill in a free-text variable; for other variables it changes a value only when a change makes sense. |
range | {"min": 0, "max": 100}, both required, inclusive. The proposed value must be a finite number within the bounds. |
length | {"min": 1, "max": 4}, both required, inclusive. The proposed value must be a string whose length in characters is within the bounds. |
Each entry has exactly one rule. An entry with two rules, or none, is refused with 422 schema_violation. A name that appears twice under mutable is refused the same way. A mutable name that is absent from variables is allowed.
{
"schema": "eposyne.director.decision-request/v2",
"ask": "The player asks Mara where her son went. What does Mara say?",
"variables": {"npc_line": "", "mood": "sad"},
"mutable": [
{"name": "npc_line", "free_text": true},
{"name": "mood", "allowed_values": ["sad", "hopeful", "angry"]}
],
"validation": true
}Operations and calls
When the model may also propose an engine function, declare the functions under operations. The reply then carries a calls array with at most limit entries (0 or 1, default 1), each naming a declared operation and supplying every declared parameter with a value of its type, within its allowed_values or range. A call that names an undeclared operation, misses an argument, adds one, or has a wrong type is dropped, never passed through. request_id is yours: use it to apply a proposal only to the request it was made for.
{
"schema": "eposyne.director.decision-request/v2",
"ask": "The player offers the guard a bribe of ten coins. What does the guard do?",
"variables": {"guard_line": "", "guard_mood": "wary", "coins_offered": 10},
"mutable": [
{"name": "guard_line", "free_text": true},
{"name": "guard_mood", "allowed_values": ["wary", "greedy", "angry"]}
],
"operations": {
"request_id": "req-7f3a",
"limit": 1,
"available": [
{"id": "Guard.OpenGate", "description": "Open the town gate for the player.", "parameters": []},
{"id": "Guard.CallReinforcements", "description": "Summon more guards.",
"parameters": [{"name": "count", "type": "integer", "range": {"min": 1, "max": 4}}]}
]
}
}{
"schema": "eposyne.director.decision-response/v2",
"changes": [
{"name": "guard_line", "value": "Ten coins buys you nothing but my silence. For now."},
{"name": "guard_mood", "value": "greedy"}
],
"calls": [
{"id": "Guard.CallReinforcements", "arguments": {"count": 2}}
]
}A parameter has a name, a type (string, number, integer, or boolean), and optionally a description, allowed_values, or range. Up to 256 operations with up to 64 parameters each.
Validation
"validation": trueWith validation on, Director makes a second model call that reviews generated text for promises the character cannot keep, for example an NPC who offers a free item when no variable allows it. The corrected text replaces the original. The pass only runs when the reply contains longer text, it costs an extra model call, and it roughly doubles latency. Use it for player-facing dialogue. Skip it for enum and number decisions. validation overrides the deployment's default for this one request.
Metadata
{
"schema": "eposyne.director.decision-request/v2",
"ask": "Does the merchant lower the price?",
"variables": {"price": 120},
"mutable": [{"name": "price", "range": {"min": 80, "max": 120}}],
"metadata": {"scene": "market-02", "attempt": 1}
}The response carries the same object back as metadata. Use it to correlate a reply with the scene or the frame that asked.
Reasons per change
Add rationale=true to the URL to get a short reason with every change, for example /v2/decide?rationale=true. Without it, or with rationale=false, a change has only name and value, which saves output tokens and time on every call. The reason is written after the value, so asking for it does not change the decision. Ask for it while you develop and leave it out in a shipped game. Any value other than true or false, or the parameter twice, is a 400 with "code": "invalid_rationale".
The response document
This example was made with rationale=true; without it the rationale members are absent.
{
"schema": "eposyne.director.decision-response/v2",
"changes": [
{
"name": "guard_stance",
"value": "hold",
"rationale": "Dignitary protection takes priority over one thief."
}
]
}| Member | Meaning |
|---|---|
schema | Exactly eposyne.director.decision-response/v2. |
changes | Always present. Zero or more entries. Empty means no suggestions. |
changes[].name | One of your mutable variables. Never anything else. |
changes[].value | The suggested value, with its JSON type. It satisfies the rule you declared. |
changes[].rationale | Only with rationale=true. A short explanation, up to 200 characters. Useful for logs and design review. Do not parse it. |
calls | Only when the request declared operations. At most limit entries, each an id and an arguments object. |
plots | Only for a decision at a stream position with Plots on: what Plots did for the player. See Plots. |
metadata | Only when the request sent it. The same object, echoed. |
There is at most one change per variable. Everything else the model wrote is dropped before the reply reaches you. A response with no suggestions looks like this:
{"schema": "eposyne.director.decision-response/v2", "changes": []}A model output that is not one JSON object of the expected shape gives the same empty reply, with the call charged. Director repairs nothing and makes no second attempt.
Ignore members and headers you do not recognize. We may add them within /v2.
Response headers
A successful decision carries these headers. The token and cost headers appear only when the model backend reported usage, so a missing one is not an error.
| Header | Meaning |
|---|---|
X-LLM-Provider | The provider that answered. |
X-LLM-Model | The model that answered. |
X-LLM-Prompt-Tokens | Input tokens used. |
X-LLM-Completion-Tokens | Output tokens used. |
X-LLM-Total-Tokens | Input plus output tokens. |
X-LLM-Cost-Usd | Cost as a decimal string with six fractional digits, in US dollars. With validation on, the figures cover both model calls. |
X-LLM-Max-Tokens | The output-token cap the call ran with. Set it with max_tokens; see Models and usage. |
X-LLM-Finish-Reason | stop, length, content_filter, or other. length with no change means the answer was cut off at the cap. |
Your credits are in euro. To see your spend, read GET /v2/usage/me instead of converting the dollar header.
Media rules
- Every
/v2route that takes a body takesapplication/json. A body with a missingContent-Type, with an XML media type, or with any other type, is a 415unsupported_media_type. Director never guesses a format from the first byte of the body. - Every
/v2route answersapplication/json; charset=utf-8, on success and on error. AnAcceptheader that admits no JSON is a 406not_acceptable. - A body that is not a JSON document, repeats a member name anywhere, holds a non-finite number, or nests deeper than 32 levels is a 422 with
invalid_json,duplicate_key, ornesting_too_deep. JSON that is not the request shape is a 422schema_violationwhosedetailnames the member. - Every path under the earlier prefix
/v1is a 404contract_retired. The earlier format with its XML form is not served by this runtime.
The codes and their bodies are on Errors and retries.
Limits
| Limit | Value | When exceeded |
|---|---|---|
| Request body | 262,144 bytes | 413 body_too_large |
| Prompt size | 30,000 input tokens | 413 prompt_too_large |
| JSON nesting depth | 32 levels | 422 nesting_too_deep |
| Variables and mutable entries | 1,024 each | 422 schema_violation |
| Rationale length | 200 characters | Cut to 200 |
| Model call time | 30 seconds | 502 |
| Request rate | 60 per minute per account | 429 |
Set your client timeout to at least 35 seconds, and to 70 seconds when validation is on. Fire the request before the player reaches the decision point, and fall back to scripted behavior while you wait.