Introduction
Xiao DSL for Ktor Server is a YAML-inspired language for defining what happens when an HTTP client hits a route on the Ktor Server Playground tool.
You paste DSL into a route's Response body field in the Xiao Android app. The on-device server parses the text, runs optional database and network steps, resolves dynamic tokens, and returns an HTTP response.
What You Can Build
- REST APIs backed by SQLite on the phone (CRUD without a laptop server).
- Endpoints that expose live device data (battery, Wi-Fi IP, model, Android version).
- Validation gates that return HTTP 400 with custom error messages.
- Proxies that forward GET requests to external URLs and return the body.
- Simulated slow APIs using
delay:steps. - Counters and flags using the built-in
kv_storekey-value table.
Three Blocks
| Block | Required | Role |
|---|---|---|
@settings: | No | SQLite table configuration and error policy |
@execute: | No | Ordered list of side effects before responding |
@return: | No | HTTP status, Content-Type, headers, and body sent to the client |
All three blocks are optional. An empty @return: with defaults yields HTTP 200 and an empty body.
DSL vs Plain Text
If the response body does not contain a line matching @settings:, @execute:, or @return: as block headers, the server treats the entire field as plain text: tokens are resolved once, and Content-Type is guessed from the first character ({ or [ = JSON, < = HTML, else text/plain).
{{token_name}}. This manual is aligned with Xiao 1.1.0.Getting Started
From zero to a working endpoint in Xiao.
Where to Write DSL
- Open Playground → Ktor Server.
- Stop the server if it is running (routes cannot be edited while running).
- Tap Add Route.
- Choose method and path (path must start with
/and cannot be only/). - Paste DSL into Response body. Tap See Help for the in-app copy of this reference.
- Save, enable the server, call
http://<phone-ip>:<port><path>from another device.
Minimal Examples
Plain JSON ping (no blocks)
{"ok": true, "server_time": "{{datetime}}", "ip": "{{Wi-Fi_ip}}"}
DSL echo (return only)
@return:
status: 200
content_type: application/json
body: {"echo": "{{request.body}}"}
Save JSON body to SQLite
@settings:
table: events
primary_key: id
@execute:
- set: [id, payload] from request.body
- set: id = {{uuid}}
@return:
status: 201
body: {"saved": true, "id": "{{col.id}}"}
content_type: in @return or from plain-text sniffing.Request Processing Pipeline
Exact order of operations per HTTP request.
- Read body — Request body is read once into a cached string.
- Detect mode — If no block headers exist, resolve tokens on raw body and respond. Stop.
- Parse DSL — Split into
@settings,@execute,@return. Parse failure returns HTTP 500 JSON error. - Prepare table — If
@settingsnames a table,ensureTableruns. - Run @execute — Each directive runs top to bottom in a single coroutine.
- Flush writes — If table is set and
set:steps accumulated columns, ensure columns then INSERT/UPSERT. - Build @return body — Substitute tokens via DSLVarResolver and DynamicResponseResolver.
- Response headers — Each
@return headers:entry resolved. - Respond — Send body with status and content_type from
@return.
Token Resolution Order
| Order | Tokens / features |
|---|---|
| 1 | {{timestamp}}, {{datetime}}, {{random}}, {{uuid}} |
| 2 | {{device_model}}, {{device_manufacturer}}, {{android_version}}, {{sdk_int}} |
| 3 | {{battery_level}}, {{battery_status}}, {{battery_voltage}} |
| 4 | {{Wi-Fi_ssid}}, {{Wi-Fi_rssi}}, {{Wi-Fi_ip}} |
| 5 | {{request.path}}, {{request.method}}, {{request.uri}}, {{request.param.*}}, {{request.header.*}}, {{request.body}}, {{request.body.*}} |
| 6 | {{db.set.*}}, {{db.incr.*}}, {{db.decr.*}}, {{db.del.*}}, {{db.get.*}}, {{db.count.*}} |
| 7 | {{eval: ...}} then {{if: ... | ... | ...}} |
Write Mode After set: Steps
| Directive seen | SQLite write verb |
|---|---|
| (none, default) | INSERT OR REPLACE (upsert) |
upsert: true | INSERT OR REPLACE |
upsert: false | INSERT (fails on duplicate primary key) |
insert: true | INSERT |
insert: ignore | INSERT OR IGNORE |
Plain Text Mode
Responses without DSL block headers.
Plain mode activates when the response body lacks any line matching @settings:, @execute:, or @return: as a block header.
Content-Type Sniffing
| Body starts with | Content-Type |
|---|---|
{ or [ | application/json |
< | text/html |
| Anything else | text/plain |
All DynamicResponseResolver tokens are supported. No @execute (no validate, query, fetch, or automatic table writes from set:).
<html><body><h1>Device</h1><p>IP: {{Wi-Fi_ip}}</p><p>Battery: {{battery_level}}%</p></body></html>
@settings Block
Database binding and error policy.
Header line must be exactly @settings:. Following lines are key: value pairs. Leading - on keys is allowed.
Keys Reference
| Key | Default | Behavior |
|---|---|---|
table | (none) | SQLite table name for writes |
create_if_missing | true | CREATE TABLE IF NOT EXISTS when true |
primary_key | id | Column name for CREATE TABLE and upsert conflict target |
on_error | respond | respond: HTTP 500 JSON on execute errors. silent: skip error response and build @return with empty execute results. |
@settings:
table: users
create_if_missing: true
primary_key: email
on_error: respond
@execute Block
Every directive in depth. Header: @execute:. Each directive is one line; leading - is optional.
set: and upsert/insert
| Directive | Description |
|---|---|
set: [col1, col2] from request.body | Map JSON object keys to columns |
set: [col1, col2] from request.params | Map query string parameters |
set: [col1, col2] from request.headers | Map HTTP header values |
set: column = value | Single column; value is token-resolved |
upsert: true | false | INSERT OR REPLACE vs plain INSERT |
insert: true | ignore | Plain INSERT or INSERT OR IGNORE |
delete: where ... | DELETE FROM table WHERE predicate |
update: SET ... where ... | UPDATE with token-resolved SET and WHERE |
query: SELECT ... [into var] | First row only; use {{query.var.col}} |
query_all: SELECT ... into var | All rows as JSON array in {{query_all.var}} |
validate: expr | message | HTTP 400 if expression is empty, 0, or false |
delay: ms | Suspend 0–30000 ms for latency simulation |
local: key = value | Scratch variable at {{local.key}} |
fetch: URL [into var] | Outbound HTTP GET with optional headers block |
@execute:
- validate: {{request.body.email}} | email is required
- set: [email, name] from request.body
- set: created_at = {{datetime}}
- upsert: false
- query: SELECT name FROM users WHERE email = '{{request.param.email}}' into u
- fetch: https://api.example.com/data into data
headers:
Authorization: Bearer {{request.header.Authorization}}
@return Block
Status, headers, and body.
| Field | Default | Notes |
|---|---|---|
status: | 200 | Integer HTTP status |
content_type: | application/json | Passed to ContentType.parse |
headers: | (block) | Indented key: value lines |
body: | (empty) | Inline value or body: | for multiline |
@return:
status: 201
content_type: application/json
headers:
X-Request-Id: {{uuid}}
X-Battery: {{battery_level}}
body: |
{
"id": "{{last_id}}",
"email": "{{col.email}}",
"at": "{{datetime}}"
}
Token Encyclopedia
Every {{token}} supported by the engine. Tokens are case-sensitive.
Execute / Write Results
| Token | When available | Value |
|---|---|---|
{{last_id}} | After insert/upsert flush | SQLite rowid; blank if -1 |
{{col.NAME}} | After flush | String written to column NAME |
{{query.VAR.COL}} | After query: into VAR | Column from first row |
{{query.VAR}} | After query: into VAR | JSONObject of first row |
{{query_all.VAR}} | After query_all: into VAR | JSONArray of all rows |
{{local.KEY}} | After local: KEY = | Scratch value |
{{fetch.VAR}} | After fetch: into VAR | Raw response body |
{{fetch.VAR.a.b}} | JSON response | Dot-path into parsed JSON |
Time, Device, Battery, Wi-Fi
| Token | Resolves to |
|---|---|
{{timestamp}} | Unix milliseconds |
{{datetime}} | yyyy-MM-dd HH:mm:ss |
{{uuid}} | Random UUID v4 |
{{random}} | Random integer 0–999999 |
{{device_model}} | Build.MODEL |
{{device_manufacturer}} | Build.MANUFACTURER |
{{android_version}} | Build.VERSION.RELEASE |
{{sdk_int}} | API level |
{{battery_level}} | Percentage 0–100, or -1 |
{{battery_status}} | charging, full, discharging, not_charging, unknown |
{{battery_voltage}} | Volts with two decimal places |
{{Wi-Fi_ssid}} | SSID with quotes stripped, or unknown |
{{Wi-Fi_rssi}} | RSSI dBm as string |
{{Wi-Fi_ip}} | IPv4 from Wi-FiManager DHCP |
HTTP Request & Key-Value Store
| Token | Effect / Value |
|---|---|
{{request.body}} | Cached raw body |
{{request.body.FIELD}} | Top-level JSON string field |
{{request.path}} | URI path before ? |
{{request.method}} | GET, POST, PUT, DELETE, etc. |
{{request.uri}} | Path and query string |
{{request.param.NAME}} | Query parameter (first value) |
{{request.header.NAME}} | Header value (case-sensitive) |
{{db.get.KEY}} | Read kv_store value or empty |
{{db.set.KEY=VALUE}} | Write VALUE; expands to empty |
{{db.incr.KEY}} | Atomic increment; returns new value |
{{db.decr.KEY}} | Atomic decrement |
{{db.del.KEY}} | Delete key |
{{db.count.TABLE}} | COUNT(*) on TABLE; 0 on error |
Expressions: {{eval:}} and {{if:}}
Inline logic and arithmetic.
{{eval: EXPRESSION}}
Supports numbers, quoted strings, boolean literals, operators + - * / %, comparisons, logical && || !, and parentheses.
{{eval: {{battery_level}} * 2}}
{{eval: (10 + 5) * 3}}
{{eval: "active" == "active"}}
{{if: EXPR | WHEN_TRUE | WHEN_FALSE}}
Separator between parts is space-pipe-space: |. WHEN_FALSE is optional (defaults to empty).
{{if: {{battery_level}} < 20 | low | ok}}
{{if: {{request.param.verbose}} | debug_on}}
| inside EXPR without careful quoting; the parser splits on | with limit 3.SQLite Database
Schema, kv_store, and SQL Runner.
Database file: ktor_server.db (version 1), singleton per app process.
Built-in kv_store Table
CREATE TABLE kv_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL DEFAULT '')
Powers all {{db.*}} tokens. Survives across routes and server restarts.
SQL Runner (Playground UI)
| Statement type | Result UI |
|---|---|
| SELECT, PRAGMA, EXPLAIN, WITH | Column/row table |
| INSERT, UPDATE, DELETE, etc. | Verb and rows affected count |
| Invalid SQL | Error message in table |
Errors and HTTP Status Codes
| Condition | HTTP | Body shape |
|---|---|---|
| Plain mode success | 200 | Resolved plain/JSON/HTML |
| DSL success | @return status | @return body |
| DSL parse error | 500 | {"error":"DSL parse error: ..."} |
| validate: failed | 400 | {"error":"your message"} |
| Execute error + on_error respond | 500 | {"error":"Execute error: ..."} |
| Execute error + on_error silent | @return status | @return body (empty execute vars) |
| Return build error | 500 | {"error":"Return template error: ..."} |
Recipes and Examples
Copy-paste endpoints for common patterns.
1. POST /register — create user with validation
@settings:
table: users
primary_key: email
on_error: respond
@execute:
- validate: {{request.body.email}} | email is required
- set: [email, name] from request.body
- set: registered_at = {{datetime}}
- upsert: false
@return:
status: 201
body: {"message": "registered", "email": "{{col.email}}"}2. GET /user?email= — lookup single row
@settings:
table: users
create_if_missing: false
@execute:
- validate: {{request.param.email}} | email param required
- query: SELECT name, email FROM users WHERE email = '{{request.param.email}}' into u
@return:
body: |
{"name": "{{query.u.name}}", "email": "{{query.u.email}}"}3. GET /status — device telemetry, no table
@return:
headers:
X-Request-Id: {{uuid}}
body: |
{
"battery": {{battery_level}},
"ip": "{{Wi-Fi_ip}}",
"model": "{{device_model}}",
"time": "{{datetime}}"
}4. DELETE /user?id= — delete with silent errors
@settings:
table: users
on_error: silent
@execute:
- validate: {{request.param.id}} | id is required
- delete: where id = '{{request.param.id}}'
@return:
status: 200
content_type: text/plain
body: deleted5. GET /proxy?url= — forward external GET
@execute:
- validate: {{request.param.url}} | url param is required
- fetch: {{request.param.url}} into data
@return:
body: {{fetch.data}}6. GET /users — list table as JSON array
@execute:
- query_all: SELECT id, name, email FROM users ORDER BY name into rows
@return:
body: {"users": {{query_all.rows}}, "count": {{db.count.users}}}7. POST /counter — atomic visit counter
@return:
body: {"visits": {{db.incr.page_views}}}8. GET /toggle — flip kv_store flag
@return:
body: |
{
"was": "{{db.get.feature}}",
"now": "{{db.set.feature={{if: {{db.get.feature}} | 0 | 1}}}}"
}9. GET /slow — artificial latency
@execute:
- delay: 2000
@return:
body: {"ready": true, "delayed_ms": 2000}10. PUT /item — update with validation
@settings:
table: items
primary_key: id
@execute:
- validate: {{request.param.id}} | id required
- update: title = '{{request.body.title}}', updated = '{{datetime}}' where id = '{{request.param.id}}'
@return:
body: {"updated": true, "id": "{{request.param.id}}"}11. GET /health — combined eval and battery
@return:
body: |
{
"ok": {{if: {{battery_level}} > 10 | true | false}},
"level": {{battery_level}},
"double": {{eval: {{battery_level}} * 2}}
}Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| 500 DSL parse error | Typo in @block header or directive syntax | Match grammar in @execute; no PATCH in route UI |
| 400 with custom message | validate: failed | Check param/body tokens resolve non-empty |
| 500 Execute error | SQL, missing table, fetch failed | SQL Runner test query; create_if_missing; check URL |
| Empty {{query.*}} | No matching row | Verify SELECT and param tokens |
| Empty {{fetch.x.y}} | Not JSON or wrong path | Use {{fetch.var}} first; validate JSON shape |
| Wrong Content-Type | Used route badge not @return | Set content_type: in @return |
| Duplicate key on insert | upsert: false with existing PK | Use upsert: true or insert: ignore |
| Column not created | set: from body field missing in JSON | Add set: col = value or fix JSON keys |
| on_error silent but odd body | Execute failed early | Check Logs; fix SQL; use respond for debugging |
Enable Verbose logging in Xiao Settings and filter Logs by tag KtorServer for per-request traces.
Quick Reference Card
One-page syntax lookup.
Block headers
@settings:
@execute:
@return:
Execute directives
set: [a,b] from request.body|request.params|request.headers
set: col = value
upsert: true|false
insert: true|ignore
delete: where ...
update: SET ... where ...
query: SELECT ... [into var]
query_all: SELECT ... into var
validate: expr | message
delay: ms
local: key = value
fetch: URL [into var]
headers:
Name: value
@return fields
status: 200
content_type: application/json
headers:
X-Custom: value
body: text
body: |
multiline
@settings keys
table: name
create_if_missing: true|false
primary_key: id
on_error: respond|silent
Author: Saurav Sajeev (Developer) · Document date: June 04, 2026