Steps and placeholders
The reference for what an agent may put inside workflow.
Step fields
Only one step type exists today: request, one outbound HTTP call.
| Field | Required | Default | Description |
|---|---|---|---|
type | yes | — | request. An unknown type rejects the whole workflow — a step kind we do not understand must not silently disappear from a chain you believe ran end to end. |
url | yes | — | May be built entirely from placeholders. Checked against the SSRF guard after substitution. |
method | no | POST | GET, POST, PUT, PATCH or DELETE. |
headers | no | {} | Object of name → value. See the reserved names below. |
body | no | — | A string is sent verbatim. An object or array is JSON-encoded and gets content-type: application/json, unless the step sets a content type itself. Mutually exclusive with form. |
form | no | — | Object of field name → string value, sent as multipart/form-data. A field whose whole value is ${{email.attachments.N}} goes out as a binary file part. Mutually exclusive with body. A content-type header on a form step is dropped — the boundary is ours to set. |
expected_status | no | any 2xx | The single status code that counts as success, e.g. 201. |
label | no | — | Free text, shown alongside the step in the API and the back office. |
Up to 20 steps per workflow.
Reserved header names
These are rejected at validation time, before anything runs:
Host,Content-Length,Transfer-Encoding,Connection,Upgrade,Proxy-Authorization— they would let a step contradict the request we actually send.- Any
x-kobana-*name — reserved, so a destination can never be fooled into reading an agent-authored header as one of ours (webhook signatures live in that space).
Header names must be a valid HTTP token, and neither names nor values may contain CR or LF — that is what stops header injection through a string the model composed.
Placeholders
Four namespaces, and only four. Anything else is an error, never text left in the request.
Whitespace inside the braces is tolerated: ${{ secrets.TOKEN }} and ${{secrets.TOKEN}} are the same reference.
${{secrets.KEY}}
A workspace secret. The value is substituted into the outbound request and redacted out of everything we persist.
{ "headers": { "Authorization": "Bearer ${{secrets.FINANCE_TOKEN}}" } }
A reference to a key that does not exist fails the step before any request is sent. The alternative — shipping the literal text ${{secrets.TYPO}} to a third party — fails anyway and leaks the reference into someone else's logs.
${{kobana.*}}
Values the platform exposes about the run. All non-secret: ids and environment names you already see in the API.
| Reference | Description |
|---|---|
kobana.env | development, sandbox or production. |
kobana.now | ISO 8601 timestamp, stamped when the step starts (not when the run was created). |
kobana.app_url | Base URL of the platform for this environment. Useful for building a link back; note that /v1 endpoints still require mTLS + JWT, so a destination cannot simply fetch one. |
kobana.workspace_id | Workspace of the run. |
kobana.workspace_slug / kobana.workspace_external_id | When set on the workspace. |
kobana.inbox_id / kobana.inbox_email | Inbox that received the message. |
kobana.email_id / kobana.email_message_id | The e-mail being processed. |
kobana.agent_id / kobana.agent_run_id | The agent and the run that produced the workflow. |
kobana.workflow_run_id | The run itself. |
The list is additive: new keys may appear, an existing key never changes meaning.
Useful pattern — make the record in your system point back at the e-mail:
{ "body": { "amount": 1250.5, "source_email_id": "${{kobana.email_id}}" } }
${{steps.N.response.…}}
Output of an earlier step in the same run. N is the 0-based position.
| Reference | Resolves to |
|---|---|
${{steps.0.response.status}} | The status code, as a string. |
${{steps.0.response.body.data.id}} | A path into the response body, parsed as JSON when the destination says so. |
${{steps.0.response.body.items.0.id}} | Array indexes are plain numbers. |
${{steps.0.response.headers.location}} | A response header. |
Rules:
- Only earlier steps resolve. A forward reference fails the step. A reference to a step that failed or was skipped makes the step
skipped— it is never sent. - A value that resolves to an object or array is JSON-encoded into the slot.
- A value that resolves to
nullfails the step — it is almost always a mistaken path, and sending the stringnullto your ERP is worse than stopping. - Property lookup is own-properties only:
constructor,__proto__and friends do not resolve.
${{email.attachments.N}}
An attachment of the e-mail that triggered the run; N is its 0-based position in the message. The file never passes through the model. The agent writes the reference, and the platform reads the original .eml and puts the bytes into the request at send time — a 5 MB PDF costs the agent the same handful of tokens as any other placeholder.
What the reference resolves to depends on where it sits:
| Where | Resolves to |
|---|---|
The whole value of a form field | The file itself: a binary multipart/form-data part carrying the attachment's bytes, filename and content type. |
Anywhere inside a string — a JSON body, a header, a form field mixed with other text | The file's base64. ${{email.attachments.N.base64}} says the same thing explicitly. |
Metadata is available as strings: ${{email.attachments.N.filename}}, ${{email.attachments.N.content_type}} and ${{email.attachments.N.size}} (bytes).
The content type is the one the mailer declared, unless it declared nothing useful (application/octet-stream), in which case it is inferred from the extension. That matters for a destination that checks the declared type against the file's magic bytes — the Kobana Finance attachment upload is one.
Multipart, for an API that takes a file upload. This is the Kobana Finance POST /attachments contract (file binary, title optional, 201 on success):
{
"type": "request",
"method": "POST",
"url": "${{secrets.FINANCE_API_URL}}/attachments",
"headers": { "Authorization": "Bearer ${{secrets.FINANCE_TOKEN}}" },
"form": { "file": "${{email.attachments.0}}", "title": "${{email.attachments.0.filename}}" },
"expected_status": 201
}
Base64, for an API that only takes JSON:
{
"type": "request",
"method": "POST",
"url": "https://erp.example.com/documents",
"body": {
"filename": "${{email.attachments.0.filename}}",
"content_type": "${{email.attachments.0.content_type}}",
"content": "${{email.attachments.0.base64}}"
}
}
Prefer form whenever the destination accepts it: no 33% base64 overhead, and no multi-megabyte string inside a JSON document.
Rules:
- A reference to an attachment the e-mail does not have fails the step before any request is sent, with a message saying how many attachments there are.
- A run whose e-mail can no longer be read (purged by retention) fails the step the same way.
- The stored copy of the request never carries the bytes: a
formfile field is recorded as{ "attachment": 0, "filename": …, "content_type": …, "size": … }, and base64 inside a body is replaced by[attachment 0: nota.pdf, application/pdf, 12345 bytes]. - Do not ask the agent to write the file contents itself. Base64 emitted as model output costs tens of thousands of tokens per megabyte and comes out corrupted; the reference is the whole point.
Worked example
Create, then attach, then notify — with the notification carrying data from both earlier calls:
{
"result": { "supplier": "ACME", "amount": 1250.5 },
"workflow": [
{
"type": "request",
"label": "create payable",
"method": "POST",
"url": "https://erp.example.com/payables",
"headers": { "Authorization": "Bearer ${{secrets.FINANCE_TOKEN}}" },
"body": { "supplier": "ACME", "amount": 1250.5, "source_email_id": "${{kobana.email_id}}" },
"expected_status": 201
},
{
"type": "request",
"label": "attach the original document",
"method": "POST",
"url": "https://erp.example.com/payables/${{steps.0.response.body.data.id}}/attachments",
"headers": { "Authorization": "Bearer ${{secrets.FINANCE_TOKEN}}" },
"form": { "file": "${{email.attachments.0}}", "kobana_email_id": "${{kobana.email_id}}" }
},
{
"type": "request",
"label": "notify the finance channel",
"method": "POST",
"url": "${{secrets.SLACK_WEBHOOK_URL}}",
"body": { "text": "Payable ${{steps.0.response.body.data.id}} created at ${{kobana.now}}" }
}
]
}
Note the second step: the e-mail's first attachment is uploaded as a file, without the agent ever seeing its bytes. And the third: the whole URL is a secret. That is supported, and the resolved URL still goes through the SSRF check before the connection is made.
Writing the prompt
The platform executes the workflow; the agent writes it. A system message that works:
When you have finished analysing the e-mail, answer with a JSON object containing
result(your analysis) and, when an action is required,workflow: an array of steps. Each step is{"type":"request","method":…,"url":…,"headers":…,"body":…,"expected_status":…}, or withforminstead ofbodyfor a multipart upload. Use${{secrets.NAME}}for credentials — never write a credential literally —${{steps.N.response.body.…}}to reuse a value returned by an earlier step, and${{email.attachments.N}}as the value of aformfield to upload the N-th attachment of the e-mail — never write file contents yourself. When no action is required, returnresultonly.
Two things worth stating explicitly in your prompt:
- When not to emit a workflow. An agent that is unsure should return
resultalone. Omitting the key is always safe; guessing an endpoint is not. - Which statuses to expect.
expected_statusis what turns a silent200 {"error": …}from a lenient API into a failed step.