Skip to main content

:::note Generated reference Generated from honeyframeapi 0.1.8 — do not edit by hand. :::

honeyframeapi.webapp

Webapp handles — publish a dashboard as a shareable webapp, then mint public share links from code.

Webapps are the last mile of the vibe-code loop: you've authored dbt models, built a Dashboard, and now want to hand a stakeholder a URL they can open without an account. A webapp is a published asset (asset_type='webapp') whose config lays out pages, nav, filters and card_ref blocks that point at dashboard cards.

proj = client.get_project("sales")
dash = proj.create_dashboard("Ops Overview")
dash.add_card("Visits", "kpi", "SELECT COUNT(*) AS n FROM marts.fact_orders")

app = client.publish_webapp(
"ops-overview", "Ops Overview",
config={"pages": [{"key": "home", "title": "Home",
"blocks": [{"kind": "card_ref",
"dashboard_id": dash.dashboard_id,
"card_id": dash.cards[0].card_id}]}]},
)
link = app.create_public_link(label="exec share", expires_days=30)
print(link["token"]) # shown ONCE — store it
print(link["share_url"]) # /public/apps/ops-overview?token=… (SaaS host)

Public links are token-gated and serve a PII-masked view (the same masking the authed /public/apps route applies). The raw token is returned exactly once by Webapp.create_public_link; later Webapp.public_links calls return metadata only.

class Webapp​

A published webapp, keyed by asset_key (e.g. 'sales360').

Webapp.reload()​

reload() -> Webapp

Fetch the webapp (incl. full config) and cache it.

Webapp.title​

property

title: Optional[str]

Webapp.is_public​

property

is_public: bool

Webapp.config​

property

config: dict

The page/nav/filter layout (call reload first if stale).

Webapp.project_id​

property

project_id: Optional[int]

Webapp.execute_block()​

execute_block(page_key: str, block_idx: int, params: Optional[dict] = None) -> dict

Run one page block's server-side SQL; returns {columns, rows, row_count, execution_ms, truncated, error}.

Addresses the block positionally — block_idx is its index in config["pages"][…]["card_refs"] — and runs the SQL the config already holds (a code block's sql, an html block's data_sql). The query is never sent from here; params only binds the filter values its :name placeholders reference, so this is a read-only call a read_only PAT can make.

Errors come back in-band as the error key (with empty rows) rather than as an exception — a query that times out or fails still returns the envelope. truncated is True when the result hit the 5000-row cap.

app = client.get_webapp("sales360_finance")
res = app.execute_block("cashflow", 2, {"period": "YTD"})
if res["error"]:
raise RuntimeError(res["error"])
print(res["columns"], res["row_count"])

Webapp.catalog()​

catalog(*, page_key: Optional[str] = None) -> dict

Asset-scoped data catalog: which marts back this app, what they mean, and which cards use them.

GET /api/webapps/{key}/catalog, gated identically to opening the app shell — so a viewer can read it without project.edit. Scoped twice: to the ASSET (only marts behind cards this app embeds) and to the CALLER (only dashboards this principal can actually execute) — a viewer sees only their own slice, and a principal who can execute none of them gets 200 with datasets: [], never a 403. page_key narrows to one page of the app.

Never returns SQL or rows. Every key is always present (None / [] when unknown), so a client never has to branch on key presence to tell "no data" from "old backend":

{asset_key, asset_id, project_id, locale, truncated,
scope: {pages, dashboards_visible, dashboards_denied, cards_visible},
freshness_card, datasets: [...], sources: [...]}

Each dataset carries {name, schema, layer, materialization, title, description, description_locale, grain, period_column, refresh, status, sumber, sumber_teknis, caveats, tags, columns, columns_suppressed, used_by}. The authored extras (grain/period_column/refresh/status/ sumber/sumber_teknis/caveats) are what Dataset.update_settings(catalog=…) writes; description prefers the authored Bahasa prose over the dbt manifest's English one and says which in description_locale. truncated goes true when caps (50 dashboards / 100 datasets / 200 columns per dataset) clipped the response — coverage is then partial and should be reported as such.

Related, on backends new enough to serve this endpoint: every card_ref block in config (from client.get_webapp() / reload) also gains an optional card_description key — the card author's "Sumber data" prose. Treat it as optional; an older backend simply omits it, as does a card with no description.

app = client.get_webapp("sales360")
cat = app.catalog(page_key="ringkasan")
for ds in cat["datasets"]:
print(ds["name"], ds["grain"], len(ds["used_by"]))
public_links() -> list[dict]

List this webapp's share links (metadata only — never the token).

create_public_link(*, label: Optional[str] = None, expires_days: Optional[int] = PUBLIC_LINK_DEFAULT_DAYS) -> dict

Mint a token-gated public share link.

The raw token and a relative share_url are in the returned dict and are shown once — store them.

The link expires after expires_days (default 30; 1..365 allowed). expires_days=0 makes a link that never expires — opt in only when you mean it. Any other value raises ValueError (the server would silently make it never-expiring). None still means never but is deprecated in favour of 0.

revoke_public_link(link_id: int) -> None

Revoke a share link (idempotent).

Webapp.update()​

update(*, config: Optional[dict] = None, title: Optional[str] = None, description: Optional[str] = None, theme: Optional[dict] = None, change_note: str = '') -> Webapp

Overwrite the stored config/metadata (PUT /api/publish/assets/{id}).

Pass the full config you want persisted. For incremental edits prefer add_page / set_nav, which read-modify-write for you. Returns self (reloaded). Note: editing the draft does NOT republish — call publish to push it live.

Webapp.publish()​

publish() -> Webapp

Publish — makes it live at /apps/<key>.

The webapp viewer only serves published assets; a freshly-created webapp is a draft (visible only in the builder) until you publish it.

Webapp.unpublish()​

unpublish() -> Webapp

Take the webapp offline (back to draft).

Webapp.add_page()​

add_page(page: dict, *, nav: Optional[dict] = None, publish: bool = False) -> Webapp

Add (or replace, by key) a page in the config and save.

page comes from page (with blocks from card_ref / html_block / code_card). Pass nav (from nav_item) to also surface it in the sidebar, and publish=True to push live in the same call.

Webapp.set_nav()​

set_nav(nav: list) -> Webapp

Replace the whole sidebar nav (list of nav_item dicts).

Webapp.remove_page()​

remove_page(key: str) -> Webapp

Drop a page (and its matching nav entry) by key and save.

Webapp.set_filters()​

set_filters(filters: dict) -> Webapp

Replace the webapp-level filter slots (from filters).

Webapp.set_chrome()​

set_chrome(chrome: dict) -> Webapp

Replace the app-level header/rail chrome flags (from chrome).

app.set_chrome(chrome(show_sidebar_search=True, show_clock=True))

Webapp.set_theme()​

set_theme(theme: dict) -> Webapp

Replace the webapp theme tokens (primaryColor, sidebar*, …).

Webapp.update_theme()​

update_theme(patch: dict) -> Webapp

Apply a partial theme update, validated server-side against the theme registry (unknown keys dropped, values coerced/clamped). Unlike set_theme (full replace) this merges onto the current theme; set a key to None to reset it. Build patch with theme.

app.update_theme(theme(font_family="Poppins", font_scale=1.1))

Webapp.ai_theme()​

ai_theme(prompt: str, *, model: Optional[str] = None) -> dict

Vibe-code the theme in natural language — the LLM proposes a theme patch, the server validates it against the registry and applies it. Returns {"theme": <merged>, "applied": <validated patch>, ...}.

app.ai_theme("make the app font Poppins and the text a bit bigger")

Webapp.ai_edit()​

ai_edit(prompt: str, *, model: Optional[str] = None, apply: bool = False, history: Optional[list] = None) -> dict

Vibe-code the WHOLE webapp in natural language — the LLM proposes a COMPLETE new config (pages, cards, blocks, layout); the server coerces, caps page/block counts, forces every code/html SQL to a read-only SELECT, and runs the same structural validator the manual save path uses.

Returns {"config": <proposed>, "summary": [<change lines>], "prompt": …}. The /ai-edit endpoint is preview-only — it never writes, so you can read summary (and diff config against the current config) before committing — a config rewrite can drop a page, so unlike ai_theme it isn't auto-applied.

Pass apply=True to persist the proposal in one call. It's saved through the normal versioned path (the same update a manual edit uses), so the vibe-coded change is snapshotted as a new version tagged "AI: <prompt>" — versions lists it, diff_versions shows what changed, and rollback undoes it. Then publish to push live.

prop = app.ai_edit("add a revenue page with a branch bar chart")
print(prop["summary"]) # ["Added page 'Revenue' (1 card)"]
app.ai_edit("…", apply=True) # propose + versioned save

history enables a multi-turn chat: pass the prior conversation as [{"role": "user"|"assistant", "content": str}, …] so the model can resolve references to earlier turns ("now make it blue"). Thread it yourself across calls — typically the user prompts plus the returned summary lines as the assistant turns:

hist = []
r = app.ai_edit("build a 2-page sales app", apply=True, history=hist)
hist += [{"role": "user", "content": "build a 2-page sales app"},
{"role": "assistant", "content": " ".join(r["summary"])}]
app.ai_edit("now add a KPI row on page 1", apply=True, history=hist)

Webapp.versions()​

versions() -> list

List the webapp's version history, newest first.

Every save — a manual builder edit OR an applied ai_edit — writes a snapshot, so this is the single timeline you scrub through to compare hand-edited and AI-edited states. Each entry is {id, version, published_by, change_note, created_at}; an applied ai_edit is tagged "AI: <prompt>" in change_note so you can tell them apart.

Webapp.rollback()​

rollback(version: int) -> Webapp

Restore a previous version (non-destructive — it's re-applied as a NEW version, so you can go back and forth freely). Requires org-admin.

app.rollback(7) # revert to how the app looked at version 7

Webapp.diff_versions()​

diff_versions(a: int, b: int) -> dict

Diff two versions — returns {left, right, diff, theme_diff} where diff is a flat [{path, kind, left, right}] list (kind ∈ added / removed / changed). Use it to see exactly what an ai_edit changed versus a hand-edited version. Requires org-admin.

app.diff_versions(7, 8)

Webapp.leaderboard()​

leaderboard(*, board: Optional[str] = None, limit: int = 10, player_id: Optional[str] = None) -> dict

Read this webapp's leaderboard. Returns {board, top: [{rank, player_name, score}], you} — you is non-null only when player_id is given (that player's rank + saved cross-device state).

app.leaderboard() # top 10 of the default board
app.leaderboard(board="weekly", limit=25)

Webapp.submit_score()​

submit_score(player_id: str, score: float, *, player_name: Optional[str] = None, board: Optional[str] = None, state: Optional[dict] = None) -> dict

Submit a score (upsert this player's BEST + optional cross-device save blob). Mostly for tests/automation — in a real game the sandboxed runtime calls hf.submitScore(name, score) and the shell posts on the frame's behalf. Returns the refreshed board.

app.submit_score("p1", 1200, player_name="AB")

Webapp.url​

property

url: str

The authenticated browser URL for this webapp.

Webapp.public_url()​

public_url(token: str, *, host: Optional[str] = None) -> str

Build the absolute anonymous URL for a share token.

The public route lives on the SaaS host (e.g. https://apps.your-domain.com), not the API base_url the client is pointed at — pass host to target it, otherwise base_url is used as a best-effort default.

Functions​

card_ref()​

card_ref(dashboard_id: int, card_id: int, *, x: int = 0, y: int = 0, w: int = 12, h: int = 4, overrides: Optional[dict] = None) -> dict

A block embedding an existing dashboard card (the no-code path).

html_block()​

html_block(html: str, *, x: int = 0, y: int = 0, w: int = 24, h: int = 4, mode: str = 'sanitized', title: Optional[str] = None, pass_filters: bool = False, hide_header: bool = False, public_hidden: bool = False, data_sql: Optional[str] = None, emits_filters: bool = False, allow_cdn: bool = False, persist_key: Optional[str] = None) -> dict

A freeform HTML/CSS/JS block — the code-webapp escape hatch.

mode='sandboxed' runs arbitrary JS in a walled-off iframe (opaque origin: no API/JWT access, and a CSP blocks outbound network — so embed any data in the markup; a self-contained canvas game works). 'sanitized' strips <script> and is for safe rich text/banners. pass_filters forwards the page's filter params into a sandboxed iframe's query string.

data_sql (sandboxed only) is the DATA BRIDGE: a read-only SELECT the server runs (governed: read-only txn, timeout, row cap, PII-masked on public) and posts INTO the iframe via postMessage — so embedded JS renders REAL DWH data while staying network-fenced. The author's JS listens: window.addEventListener('message', e => { if (e.data?.source === 'honeyframe' && e.data.type === 'data') draw(e.data.rows); }). Re-posted on filter change; like a code block's sql it never leaves the server and is stripped from the public config.

emits_filters=True (sandboxed only) is the INTERACTIVE bridge: the block may post messages back UP to drive the page filters, so a custom viz can be clickable. The author's JS posts parent.postMessage({source:'honeyframe', type:'set_filter', param:'branch_id', value:'JTN'}, '*') (or type: 'set_filters', params:{…}). The page accepts only sanitized scalar/array values from this block's own frame, bound by name into card SQL.

public_hidden=True keeps this block's markup off anonymous (public / share-link) viewers — the server blanks it before the config leaves the backend. Use for internal notes that should only render for authed users. Sandboxed HTML is otherwise author-trusted and public-visible.

allow_cdn=True (sandboxed only) opts the block into a FIXED, vetted library-CDN allowlist (jsDelivr / unpkg / cdnjs / esm.sh): it widens the block's CSP so a game or rich app can load an engine (Phaser, Three.js, Pixi) or assets from those CDNs instead of inlining everything within the 100k-char budget. The iframe stays opaque-origin (no API/JWT access); since inline JS is already allowed, this grants libraries, not new powers.

A sandboxed block can PERSIST state across reloads (a high score, game progress) with NO server: hf.saveState(obj) stores it and await hf.loadState() restores it on load — the shell keeps it in the browser's localStorage, namespaced per webapp + block (this browser only; no cross-device sync). Pass persist_key to pin a stable slot that survives reordering the block.

There is a 100k-char per-block limit enforced server-side on save.

code_card()​

code_card(sql: str, card_type: str, *, x: int = 0, y: int = 0, w: int = 12, h: int = 6, title: Optional[str] = None, card_config: Optional[dict] = None, display_options: Optional[dict] = None) -> dict

A governed SQL→chart block (read-only SELECT, runs server-side).

card_type is any DashboardCardChart type (table/bar/line/ pie/process_map/…). For process_map the SQL should return from_activity, to_activity, transition_count (pre-aggregated edge list) or a raw event log (case_id, activity, ts).

leaderboard_card()​

leaderboard_card(*, board: Optional[str] = None, limit: Optional[int] = None, title: Optional[str] = None, x: int = 0, y: int = 0, w: int = 8, h: int = 6) -> dict

A no-code leaderboard block — renders this webapp's shared leaderboard (top scores by board) with no SQL and no game JS. Pairs with the in-game hf.submitScore / Webapp.submit_score; auto-scoped to this webapp.

page("home", "Home", [leaderboard_card(board="weekly", limit=10)])

form_card()​

form_card(form_key: str, embed_token: str, *, title: Optional[str] = None, x: int = 0, y: int = 0, w: int = 6, h: int = 8) -> dict

A data-capture FORM block — embeds an existing published form asset so the webapp can COLLECT submissions (surveys, feedback, "submit a reading", data corrections). No new write path: the field schema, validation (incl. national-ID formats), submit, CSV export, and the form→dataset pipeline all reuse the existing forms feature.

form_key + embed_token identify the source form — list yours via the platform's GET /api/publish/assets?asset_type=form (each row carries the asset_key and embed_token). Both the schema fetch and the submit resolve from the embed_token against the form's public endpoints, so the block renders identically in the authed host and an anonymous public viewer.

page("intake", "Intake", [form_card("customer-feedback", tok)])

field()​

field(name: str, label: str, type: str = 'text', *, required: bool = False, options: Optional[list] = None, validation: Optional[str] = None, placeholder: Optional[str] = None, min: Optional[float] = None, max: Optional[float] = None) -> dict

One field of an inline form. type ∈ text / textarea / number / email / date / select / toggle / checkbox. options lists choices for a select; validation="nik_format" enforces a 16-digit Indonesian NIK; min/max bound a number.

field("nik", "NIK", validation="nik_format", required=True)

form()​

form(form_id: str, fields: list, *, title: Optional[str] = None, success_message: Optional[str] = None, x: int = 0, y: int = 0, w: int = 6, h: int = 8) -> dict

A data-capture FORM block with INLINE fields — declare the form right here (no separate form asset). Submissions persist to the webapp-scoped endpoint (validated against fields, rate-limited, write-only public) and the owner reads them via GET /api/webapps/{key}/forms/{form_id}/submissions. Reuses the forms feature's validation (incl. Indonesian NIK).

form_id is a stable slug (the submit-path segment + the per-submission discriminator). fields is a list of field.

form("feedback", [
field("name", "Name", required=True),
field("rating", "Rating", "number", min=1, max=5),
field("comment", "Comments", "textarea"),
])
banner(branches_sql: str, *, title: Optional[str] = None, severity: str = 'warning', chip_limit: int = 24, x: int = 0, y: int = 0, w: int = 24, h: int = 1, locale: str = 'id') -> dict

A full-width attention banner listing entities flagged by branches_sql — one row = one chip. Columns are name-matched: label/name (chip text), optional detail/value (highlighted suffix, e.g. "Margin 4%").

title defaults to the locale's wording: "id" (the 0.1.x default, kept so existing apps render unchanged) gives "Perlu perhatian", "en" gives "Needs attention". An explicit title always wins.

Executes via the SAME governed per-block execute path a code_card uses — read-only SELECT, run server-side, never sent to the browser (and stripped from a public webapp's config payload). Renders NOTHING when the query returns zero rows or fails — never a title with no chips. There is no static/authored fallback: a banner with no live query has nothing honest to show.

banner("SELECT store_name AS label, 'Margin '||margin_pct||'%' AS detail "
"FROM marts.v_store_margin WHERE margin_pct < 5 ORDER BY margin_pct",
locale="en")

page_anchor()​

page_anchor(label: str, block_index: int) -> dict

One pill in a page's in-page anchor row (page(..., anchors=[...])).

block_index is 0-based, matching the position of the block in the blocks list passed to page — the same addressing the grid's data-hf-card-index wrapper and the ?hfcard= metric-search deep link already use. page raises if any anchor's block_index is out of range for the blocks actually passed, so a stale anchor (a block reordered or removed after the anchor was authored) is caught at build time instead of shipping a pill that scrolls nowhere.

page("overview", "Overview", [hero, breakdown, kpis],
anchors=[page_anchor("Revenue", 0), page_anchor("Breakdown", 1)])

page()​

page(key: str, title: str, blocks: list, *, subtitle: Optional[str] = None, footnote: Optional[str] = None, icon: Optional[str] = None, filters: Optional[dict] = None, header_meta: Optional[str] = None, header_alert: Optional[str] = None, header_alert_severity: Optional[str] = None, header_meta_sql: Optional[str] = None, header_alert_sql: Optional[str] = None, anchors: Optional[list] = None) -> dict

A webapp page. blocks is a list from card_ref / html_block / code_card. (Stored under the legacy key card_refs; kind discriminates each block.)

Optional extras:

  • filters — page-level filter slots (from date_range_filter / branch_filter, assembled with filters) merged over the webapp-level filters. Use when one page needs a different filter shape.
  • header_meta / header_alert (+ _severity) — the right-side header chips next to the title (freshness stamp, status pill).
  • header_meta_sql / header_alert_sql — read-only SELECTs whose first cell overrides the static chip when non-null (run server-side).
  • anchors — in-page pill nav (from page_anchor), rendered between the filter bar and the block grid. Every entry's block_index must be < len(blocks) — raises ValueError otherwise, the same discipline chrome uses to refuse a flag with nothing behind it.
nav_item(page_key: str, label: str, *, icon: str = 'circle', section: Optional[str] = None) -> dict

A sidebar nav entry pointing at a page key.

date_range_filter()​

date_range_filter(*, start_param: str = 'period_start', end_param: str = 'period_end', timezone: str = 'Asia/Jakarta', presets: Optional[list] = None, label_param: Optional[str] = None, locale: str = 'id') -> dict

A preset-driven date-range filter writing start_param/end_param.

presets is a list of {key, label?, range} dicts; when omitted a default set (month to date / quarter to date / year to date / last 30 days / previous month) is used, labelled in locale: "id" (the 0.1.x default, so existing apps render unchanged) or "en". timezone is the IANA zone the ranges are computed in; its 0.1.x default is "Asia/Jakarta" — pass your own ("UTC", "Europe/London", …). The chosen range is bound into your code-block / card SQL by the param names, e.g. WHERE order_date BETWEEN :period_start AND :period_end.

branch_filter()​

branch_filter(param: str, *, mode: str = 'multi', options: Optional[list] = None, options_sql: Optional[str] = None, empty_value: Optional[str] = None, chips: bool = False) -> dict

A branch / entity dropdown writing a single param.

mode is 'single' or 'multi'. Supply static options ([{"value", "label", "code"?}, …]) and/or an options_sql read-only SELECT returning value, label (and optional code) for a data-driven dropdown — a common pattern is to list only branches that actually have data so the filter never offers an empty selection. Public webapps fall back to the static options (options_sql is authed-only).

chips=True renders the selection as a removable chip row ("N01 — North ×") under the filter bar instead of the selected-count badge. Multi-select only — the renderer ignores it in 'single' mode (a single-select button already shows its one value), so asking for it there raises instead of silently rendering no chips.

entity_filter()​

entity_filter(param: str, *, mode: str = 'multi', options: Optional[list] = None, options_sql: Optional[str] = None, empty_value: Optional[str] = None, chips: bool = False) -> dict

An entity dropdown (store, region, team, customer segment, …) writing param — the neutral name for branch_filter, with exactly the same arguments and the same wire type (branch_select).

entity_filter("store_codes", options_sql=
"SELECT store_code AS value, store_name AS label FROM marts.dim_store")

pill_multi_filter()​

pill_multi_filter(param: str, options: list, *, label: Optional[str] = None, mode: str = 'multi', all_label: Optional[str] = None, empty_value: Optional[str] = None, chips: bool = False) -> dict

An inline pill row (REGION [All][1][2]…) writing param — the native form of the fixed-taxonomy rows pages have been hand-rolling as sandboxed HTML blocks.

Publishes the same two binds as branch_filter: param (a list in 'multi' mode, a scalar in 'single') and the CSV companion <param>_codes (each option's code, falling back to its value), so card SQL can bind <col> = ANY(string_to_array(:regional_codes, ',')) without an expanding bind. With nothing selected both publish empty_value when one is declared.

options are static and authored — there is no options_sql here: a pill row is a fixed taxonomy (6 regions, 4 tiers), and anything that needs a query belongs in branch_filter. Each entry is a {"value", "label", "code"?} dict or a bare string (used as both value and label). all_label renames the leading clear-the-group pill (the renderer's default label is the Indonesian "Semua", i.e. "All"); chips=True adds this group's selection to the same removable chip row branch_filter feeds.

pill_multi_filter("region", [{"value": 1, "label": "Region 1"}],
label="REGION", all_label="All", empty_value="ALL", chips=True)
pill_multi_filter("tier", ["A", "B", "C", "D"], label="TIER", all_label="All")

metric_search_filter()​

metric_search_filter(*, label: Optional[str] = None, placeholder: Optional[str] = None) -> dict

The page-level metric-search row. Binds NO parameter — it does not narrow the data, it finds the CARD.

Hits come from the same card-title index the sidebar search uses and route to the card's page with ?hfcard=<block index>. The row renders above the parameter slots (find the card first, then narrow it). Without label / placeholder the renderer shows its Indonesian defaults ("Cari Metrik", "Find metric"; "Ketik nama metrik…", "Type a metric name…"); pass both to label it in another language.

Use it on chromeless / collapsed-rail surfaces: an app that sets chrome(show_sidebar_search=True) already carries this search in the rail, and both at once puts two search boxes on one screen.

filters(search=metric_search_filter(label="Find metric",
placeholder="Type a metric name…"),
period=date_range_filter(locale="en"))

filters()​

filters(**slots) -> dict

Assemble named filter slots into the keyed map a filters config expects, dropping any None slot. Keys are logical names (period, branch, region); values come from date_range_filter / branch_filter / entity_filter / pill_multi_filter / metric_search_filter.

filters(period=date_range_filter(), branch=branch_filter("branch_id"))

theme()​

theme(*, primary_color: Optional[str] = None, header_background: Optional[str] = None, logo: Optional[str] = None, card_color_theme: Optional[str] = None, sidebar_background: Optional[str] = None, sidebar_text: Optional[str] = None, sidebar_active_bg: Optional[str] = None, accent_text: Optional[str] = None, font_family: Optional[str] = None, font_scale: Optional[float] = None, font_url: Optional[str] = None, assistant_width: Optional[float] = None, assistant_position: Optional[str] = None, sidebar_width: Optional[float] = None, border_radius: Optional[str] = None, content_padding: Optional[float] = None) -> dict

Build a webapp theme dict for create_webapp(theme=...) or Webapp.set_theme. Only the keys you pass are included.

Branding tokens map to the camelCase config keys the renderer honors (primaryColor, sidebar*, accentText, …). Fonts:

  • font_family — CSS font-family for the whole app (cards inherit it). Use a web-safe font, the bundled 'Inter Variable', or a family loaded via font_url.
  • font_scale — text size multiplier; the renderer clamps to 0.7–1.5.
  • font_url — optional stylesheet URL (e.g. a Google Fonts link) loaded so a custom font_family can render.

Assistant drawer:

  • assistant_width — drawer width in pixels (280–1200). Mobile always full-width.
  • assistant_position — 'right' (default) or 'left'.

Layout density:

  • sidebar_width — expanded sidebar width in pixels (160–320). Default 256.
  • border_radius — card corner rounding: 'none'/'sm'/'md'/'lg'/'xl'.
  • content_padding — main content area padding in pixels (0–48). Default 24.
app.update_theme(theme(font_family="Poppins", font_scale=1.1,
font_url="https://fonts.googleapis.com/css2?family=Poppins"))
app.update_theme(theme(assistant_width=700, sidebar_width=280, border_radius="xl"))
app.update_theme(theme(content_padding=12)) # tighter layout

chrome()​

chrome(*, show_sidebar_search: Optional[bool] = None, show_clock: Optional[bool] = None, timezone: Optional[str] = None, show_alert_pill: Optional[bool] = None, alert_pill_sql: Optional[str] = None, alert_pill_text: Optional[str] = None, alert_pill_severity: Optional[str] = None, show_notifications: Optional[bool] = None, notifications_sql: Optional[str] = None, show_share_button: Optional[bool] = None, show_print_button: Optional[bool] = None, show_theme_toggle: Optional[bool] = None, show_user_avatar: Optional[bool] = None, show_report_cart: Optional[bool] = None, show_help_fab: Optional[bool] = None, show_assistant: Optional[bool] = None, assistant: Optional[dict] = None, fullscreen: Optional[bool] = None) -> dict

Build the app-level chrome dict for create_webapp(chrome=...) or Webapp.set_chrome. Only the keys you pass are included — an absent flag means the shell renders that affordance not at all.

Header / rail affordances (all off unless turned on):

  • show_sidebar_search — search box atop the left rail; filters nav items AND the app's card titles (metric hits that jump to the block).
  • show_clock (+ timezone) — header clock pill. timezone is an IANA name (0.1.x default Asia/Jakarta; pass your own, e.g. "UTC") and is read ONLY by this pill.
  • show_alert_pill (+ alert_pill_sql / alert_pill_text / alert_pill_severity) — a status pill such as "23 stores critical".
  • show_notifications (+ notifications_sql) — header bell + dropdown.
  • show_share_button / show_print_button / show_theme_toggle / show_user_avatar / show_help_fab — header buttons and the "?" FAB.
  • show_report_cart — the ONE affordance that is ON by default; pass False to hide it.
  • show_assistant (+ assistant) — right-side assistant drawer. assistant is the wiring dict (agent_id, title, welcome_message, suggested_prompts, accent_color, icon, show_tabs, tabs).
  • fullscreen — chromeless: hide BOTH rail and header so page content fills the viewport (also forced per-view by ?fullscreen=1 / ?embed).

A flag whose content comes from a query is refused without that query, so the app cannot ship an affordance with nothing behind it: an alert pill needs alert_pill_sql (live) or alert_pill_text (static) or the shell renders nothing; a bell without notifications_sql is a dead placeholder; an assistant launcher without assistant never appears. The inverse is refused too — content passed without its flag is config the shell never reads.

There is no show_title flag: the webapp shell reads none (it exists only in the dashboard page config, a different object), so passing it here would be a silent no-op.

chrome(show_sidebar_search=True, show_clock=True, show_theme_toggle=True,
show_alert_pill=True,
alert_pill_sql="SELECT COUNT(*) || ' stores critical' "
"FROM marts.v_store_margin WHERE margin_pct < 5")

Constants​

NameValue
LOCALES('id', 'en')

Verified against honeyframeapi 0.1.8.