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
| Key | Meaning |
|---|---|
app_db | Absolute path of this app’s data/app.db |
app_dir / app_root | Pack-copy root for this invoke (includes logic/scripts/assets/references) |
platform | Platform-bridge module (fetch, save attachments); do not use urllib/requests yourself |
2.3 Return value: fields the frontend understands
| Field | Use |
|---|---|
ok | Whether it succeeded |
error | Failure explanation (shown to the user) |
message | Success hint |
upload_id | Generated attachment id → UI can “open/download” |
html_filename | Download filename hint |
warnings | List of strings: non-fatal hints such as partial fetch failure |
code: "disambiguate" | User must disambiguate (for example duplicate names) |
candidates | Disambiguation 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.
| Method | Role |
|---|---|
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
app.json·actions: add action id,label,impl: "script",params(form controls come from here).app.json·views: add or changeaction_form,actionpointing at that id.logic/handlers.py: addif action == "...":branch; validate params → business → return agreed fields.- Optional: split complex logic into
logic/xxx.py; handlers only dispatch. - Optional: need history → entity +
list; on success/failure write sqlite viaapp_db. - Optional: multi-step fetch → App data pipeline.
- 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)
| action | Who calls | Notes |
|---|---|---|
crud.list / create / update / delete | List / entity form | Standard UI already wired |
ping | Probe | Health check |
schema.tables | Debug | List 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_wallinto 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
| Symptom | Check |
|---|---|
| After submit, “unknown action” | handle does not process that id; or action id does not match the view |
| Form has no inputs | Controls come from actions[].params, not views[].fields |
| Cannot fetch | platform not injected; or query_id missing / no permission |
| Script rejected | Used a forbidden network/process API |
| Has a result but the UI cannot open it | Did not return upload_id; or the list entity has no upload_id field |