All docs

App actions

Actions and handlers (UI button → code).

Source docs/en/site/sdk-appsdk-handlers.md

Actions and handlers (UI button → code).

Readers: people who added an action form on the UI and now need the matching implementation in the pack. Related: App package layout · App data pipeline · App UI description


1. End-to-end mapping

User fills the action form and submits
  → frontend invoke(action_id, params)
    → platform: first match built-in actions (crud.* / ping / schema.tables)
    → miss: load logic/handlers.py, call handle(action, params, ctx)
      → your code: fetch / write app.db / generate attachments
    → return dict
  → frontend shows success, error, open/download, name-collision candidates, and similar per agreed fields

Principle: every impl=script actions[].id that appears in app.json should have a branch in handle; otherwise the user sees “unknown action” or a failure.


2. handlers.py contract

2.1 Required entry

def handle(action, params, ctx):
    """
    action: str   — same as actions[].id in app.json, for example "profile.generate"
    params: dict  — params submitted from the form (keys are params[].name)
    ctx: dict     — context the platform injects
    return: dict  — see §2.3; if you return None, the platform treats it as {"ok": True}
    """
    ...

Missing handle{"ok": False, "error": "missing_handle"}.

2.2 Common ctx keys

KeyMeaning
app_dbAbsolute path of this app’s data/app.db
app_dir / app_rootPack-copy root for this invoke (includes logic/scripts/assets/references)
platformPlatform-bridge module (fetch, save attachments); do not use urllib/requests yourself

2.3 Return value: fields the frontend understands

FieldUse
okWhether it succeeded
errorFailure explanation (shown to the user)
messageSuccess hint
upload_idGenerated attachment id → UI can “open/download”
html_filenameDownload filename hint
warningsList of strings: non-fatal hints such as partial fetch failure
code: "disambiguate"User must disambiguate (for example duplicate names)
candidatesDisambiguation candidate list (shape is yours; the UI will try to show it)

Other business fields can be returned as usual; the standard renderer may ignore them, but conversation/debug can use them.

2.4 Safety limits (when writing scripts)

The sandbox scans Python under logic/ and scripts/ and forbids for example: subprocess, os.system, direct socket/urllib/requests, eval/exec, and similar. Internet and data-connection access must go through ctx["platform"].


3. Platform bridge (ctx["platform"])

Injected by invoke; do not put it in the app pack.

MethodRole
query_list(source_slug=None)List currently available predefined queries
query_run(query_id, params=None, source_slug=None)Run a predefined query; returns a result including rows
save_upload(filename, content, mime_type=None)Save an attachment; returns including upload_id, download_url
default_data_source_slug()Default data-connection id (if any)

Example:

def handle(action, params, ctx):
    if action != "report.run":
        return {"ok": False, "error": f"Unknown action: {action}"}

    name = (params.get("name") or "").strip()
    if not name:
        return {"ok": False, "error": "Please fill in a name"}

    platform = ctx.get("platform")
    if platform is None:
        return {"ok": False, "error": "Platform capability not injected; cannot fetch data"}

    raw = platform.query_run("employee_by_name", {"empName": name}) or {}
    rows = (raw.get("rows") or []) if isinstance(raw, dict) else []
    if not rows:
        return {"ok": False, "error": f"Could not find “{name}”"}

    html = f"<html><body><h1>{name}</h1></body></html>"
    up = platform.save_upload(f"报告-{name}.html", html, "text/html; charset=utf-8") or {}
    return {
        "ok": True,
        "message": "Generated",
        "upload_id": up.get("upload_id"),
        "html_filename": up.get("filename"),
    }

4. How to add backend when the UI gains a feature

Step list

  1. app.json · actions: add action id, label, impl: "script", params (form controls come from here).
  2. app.json · views: add or change action_form, action pointing at that id.
  3. logic/handlers.py: add if action == "...": branch; validate params → business → return agreed fields.
  4. Optional: split complex logic into logic/xxx.py; handlers only dispatch.
  5. Optional: need history → entity + list; on success/failure write sqlite via app_db.
  6. Optional: multi-step fetch → App data pipeline.
  7. Save, then open the app to verify; if published via the “app-development assistant”, confirm the draft includes handlers_py / files.

Minimal add example

app.json (excerpt)

{
  "views": [
    { "type": "action_form", "id": "ping_box", "label": "Try run", "action": "demo.ping" }
  ],
  "actions": [
    {
      "id": "demo.ping",
      "label": "Try run",
      "impl": "script",
      "params": [
        { "name": "who", "label": "Name", "type": "text", "required": true }
      ]
    }
  ]
}

handlers.py (excerpt)

def handle(action, params, ctx):
    params = params or {}
    if action == "demo.ping":
        who = (params.get("who") or "").strip() or "friend"
        return {"ok": True, "message": f"Hello, {who}"}
    return {"ok": False, "error": f"Unknown action: {action}"}

You still need at least one entity (validation requires it); you can keep a blank notes entity + list, or a placeholder entity unrelated to the action.


5. Built-in actions (need not go in handlers)

actionWho callsNotes
crud.list / create / update / deleteList / entity formStandard UI already wired
pingProbeHealth check
schema.tablesDebugList business tables

Only custom ids go into handlers.py.


6. Usual pattern for writing the DB (history)

import sqlite3
from datetime import datetime, timezone, timedelta

def _db(ctx):
    path = (ctx or {}).get("app_db") or ""
    if not path:
        raise RuntimeError("missing app_db")
    conn = sqlite3.connect(path)
    conn.row_factory = sqlite3.Row
    return conn

def _now():
    # Always store UTC ISO; list display is converted by the platform using the account display timezone
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

def _beijing_wall(iso_utc: str) -> str:
    """For report HTML body: UTC → Beijing wall clock. Scripts cannot read account preference."""
    dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
    return (dt + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S")

Table and column names must match app.json entities (platform-generated schema). The employee-profile template registers a row on both generate success and failure so the list can show status — copy that pattern for your business.

Timezone notes:

  • Write DB / created_at: use _now() (UTC).
  • Time columns on the standard list: the platform shows them by the user’s “time display” preference; scripts need not, and should not, turn DB fields into “fake UTC+8 strings”.
  • Report cover/body printed time: write something like _beijing_wall into HTML; if the user wants it to follow account preference, say honestly that the sandbox does not inject that preference today — do not invent a platform API.
  • If the user only wants to change site-wide UI timezone: point them at preferences; do not change handlers.

7. Common failure causes

SymptomCheck
After submit, “unknown action”handle does not process that id; or action id does not match the view
Form has no inputsControls come from actions[].params, not views[].fields
Cannot fetchplatform not injected; or query_id missing / no permission
Script rejectedUsed a forbidden network/process API
Has a result but the UI cannot open itDid not return upload_id; or the list entity has no upload_id field