Playground · Ktor Server

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_store key-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).

Tokens use double curly braces: {{token_name}}. This manual is aligned with Xiao 2.0.0.

Getting Started

From zero to a working endpoint in Xiao.

Where to Write DSL

  1. Open Playground → Ktor Server.
  2. Stop the server if it is running (routes cannot be edited while running).
  3. Tap Add Route.
  4. Choose method and path (path must start with / and cannot be only /).
  5. Paste DSL into Response body. Tap See Help for the in-app copy of this reference.
  6. 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}}"}
The JSON / Plain Text / HTML selector in Add Route is a display badge only. The real Content-Type comes from content_type: in @return or from plain-text sniffing.

Request Processing Pipeline

Exact order of operations per HTTP request.

  1. Read body — Request body is read once into a cached string.
  2. Detect mode — If no block headers exist, resolve tokens on raw body and respond. Stop.
  3. Parse DSL — Split into @settings, @execute, @return. Parse failure returns HTTP 500 JSON error.
  4. Prepare table — If @settings names a table, ensureTable runs.
  5. Run @execute — Each directive runs top to bottom in a single coroutine.
  6. Flush writes — If table is set and set: steps accumulated columns, ensure columns then INSERT/UPSERT.
  7. Build @return body — Substitute tokens via DSLVarResolver and DynamicResponseResolver.
  8. Response headers — Each @return headers: entry resolved.
  9. 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}}
Do not use | 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: deleted

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