Skip to main content
Welcome to AbstraOverviewAuthenticationOverviewTreasury DashboardAuthenticated Task ManagerExpense Submission FormSystem Status MonitorFiltered Orders TableStreaming Progress BarStreaming File DownloadStreaming Video with Media Source ExtensionsFile uploads in Pagesget_query_paramsget_userregister_staticOverviewBasicsExternal DataReactive PagesPre-filled fieldsRedirectCustom ValidationProgress BarCustom ButtonsGetting User InformationQuery ParametersCRUD with DatabaseClient-side JavaScriptOverviewAppointmentInputCardsInputCheckboxInputChecklistInputCnpjInputCodeInputCpfInputCurrencyInputCustomInputDateInputDropdownInputEmailInputOverviewCameraInputImageInputVideoInputListInputMultipleChoiceInputNpsInputNumberInputNumberSliderInputPandasRowSelectionInputPasswordInputPhoneInputRatingInputRichTextInputRowTagInputTextInputTextareaInputTimeInputToggleInputOverviewFileOutputHtmlOutputIframeOutputImageOutputLatexOutputLinkOutputMarkdownOutputPandasOutputPlotlyOutputProgressOutputTextOutputStep TypesAuthenticationForm SettingsNotificationsHome Page and SidebarOverviewBackButtonExitButtonNextButtonend_page_stepexecute_jsget_query_paramsget_userListItemSchemarunWidgetOverviewBasic HookReceiving Connector EventsReceiving EmailsTriggering Hooksget_email_requestget_raw_requestget_requestsend_jsonsend_responseJobsOverviewSending a taskGetting and using TasksIterate through tasksGet trigger task (Tasklet)TaskletsTask Schema Validationget_sent_tasksget_tasksget_trigger_taskiter_taskssend_taskTaskOverviewPrompt ConstructionToolsExamplesWorkflowAgentsPromptingFixed answer formatReading an InvoiceParsing DocumentsOverviewBrowserToolsConnectorsToolsFilesToolsTablesToolsextract_textparse_bank_statementparse_boletoparse_invoiceparse_nfeparse_nfseparse_oficiosparse_us_driver_licenseparse_us_passportpromptrun_agentOverviewCreating and Loading FilesPublic Filesget_persistent_dirget_public_dirOverviewThe custom actionrun_connection_actionDatabricksItaú ExtratosOverviewConsultasEmissão de NFS-eEventos de NFS-eTroubleshootingGoogle DriveOverviewEnvio de Lote de RPS (Versão 2)Itaú VAN (SFTP)OverviewReading dataSupplier invoice (MIRO)Bank statementTransactional sessionsValue helpsTroubleshootingAbstra Cash ConnectOverviewIssuing an NF-eEvents: Cancellation and Correction LetterTroubleshootingBlingBradescoOverviewAttaching Files to NetSuite RecordsRetrieving and Updating Files in NetSuiteSearching Files Attached to RecordsSearching Files in NetSuite File CabinetUploading Files to NetSuite File CabinetCreating Custom Reports with NetSuite Purchase OrdersCreating Purchase Orders and Attaching Files in NetSuiteFiltering Purchase Orders by Custom FieldsReading Purchase Order Line ItemsReading Purchase Order DetailsSearching and Filtering Purchase OrdersOverviewGetting Invoice Details and TransactionsListing and Searching Invoices99Abstra Manager APIAbstra Project APIADN NFS-eAdyenAnchorage DigitalAsaasAmazon Web ServicesMicrosoft AzureBanco ABCBanco do BrasilBanrisulBase ChainBanco Central do BrasilBetha e-Nota CloudBigDataCorpBinanceBitstampBlingBotConversaBradescoBrasil APIBrazeBrexBS2BTG Pactual Empresas (Custom App)BTG Pactual EmpresasAbstra Cash ConnectCelcoinCerttaCielo e-CommerceCielo EDI LinkCielo Extrato EletrônicoCieloClaraClicksignClickUpCNJ - DataJudCNJ - Domicílio Judicial EletrônicoCNJ - Diário de Justiça Eletrônico NacionalCobranSaaSCoinbase CDPCoinbase Commerce (Business)Coinbase Exchange / Prime / INTX / Derivatives (HMAC)ConfluenceConfluence (API Token)Conta AzulConta SimplesMicrosoft Copilot Studio (Custom App)CresolCRS 360Databricks AccountDatabricks WorkspaceDatatravelData Rudder (DeLorean)DFNSDiscordDocuSign (JWT)DocuSignDynamics 365 Business CentraleNotas GatewayEquals ConnectEthereumFirecrawlFiservFlashFlukeFoxbitFreshserviceGoogle Cloud PlatformGitHub (Personal Access Token)GitHubGmailGoogle AnalyticsGoogle BigQuery (Service Account)Google BigQueryGoogle Calendar (Service Account)Google CalendarGoogle Chat (Service Account)Google ChatGoogle Docs (Service Account)Google DocsGoogle Drive (Service Account)Google DriveGoogle Maps (Service Account)Google Maps (API Key)Google MapsGoogle Sheets (Service Account)Google SheetsGranatumGRF/OFMHi PlatformHolmesHubSpotIBGEiFoodInfosimplesBanco InterIntercomItaú BoletosItaú ExtratosItaú Pagamentos (SISPAG)Itaú PixItaú VAN (SFTP)IuguJD Pagamentos InstantâneosJiraJira (API Token)KommoKraken DerivativesKraken SpotLinkedInLinx OMALitecoinLookerMagaluMailchimpMCPMega ERPMercado BitcoinMercado LivreMercado PagoMercosMetabaseMicrosoft 365 Copilot (Custom App)Microsoft BookingsMicrosoft ExcelMicrosoft OneNoteMicrosoft Outlook (Service Principal)Microsoft OutlookMicrosoft PlannerMicrosoft SharePoint + OneDrive (Service Principal)Microsoft SharePoint + OneDriveMicrosoft Teams (Service Principal)Microsoft TeamsMicrosoft To DoMonday.comMXM WebManagerMySQLNektnetLexNetSuite OAuth2NetSuite TBANexxera - Nix EmpresasNexxera - Nix PayNF-e São PauloNordVPNNota BarueriNota PaulistanaNota SalvadorNotionNotion (API Token)NuPayOKXOlist EcommerceOlist ERPOmie ERPOneStreamOnFlyOracle E-Business SuiteOracle DatabasePagarePagar.mePagBank EDIPagSeguroPayPalPipedrivePipedrive (API Token)PipefyPodioPolygonPostgreSQLPostmanProJuris EmpresasProJurisQiveQuickBooksRappiRDP (Windows Remote)Recall.aiReceita Federal - Apuração Assistida CBSReceiv BIRedecard (e.Rede)Amazon RedshiftRevolut BusinessRevolut MerchantRevolut Open BankingSabespSafeguardSalesforceSankhyaSantanderSAP ABAP Development Tools (ADT)SAP Cloud ConnectorSAP ConcurSAP CPI (Integration Suite)S/4HANASAP Service LayerSBA CertificationsSentrySerproSerpro Integra ContadorSFTPShopify StorefrontShopifySicoobSicrediSiengeBanco Central do Brasil - SiscomSlack (API Token)SlackSnowflakeSolanaSQL ServerBanco Central do Brasil - STA (Sistema de Transferência de Arquivos)Stark BankStripe (API Key)StripeSVB (Silicon Valley Bank)Takotl;dvTodoistTotal BankTotvsTravellinkConsumo de Tributos GovTRONTypeformTyxterUAUWeb (Globaltec)UberVERT CapitalVindiVórtx VxRecebíveisVórtxVTEXWhatsApp BusinessWhatsApp Business (API Token)WhatsApp (RPA)WoofficeWoopayXP InvestimentosXRP Ledger (Ripple)YunoZé DeliveryZendeskZendesk (API Token)OverviewInserting rowsSelecting rowsUpdating rowsDeleting dataUsing SQLUsing comparatorsBackup & RestoreUploading CSV filesdelete_by_idinsertis_betweenis_eqis_gtis_gteis_ilikeis_inis_likeis_ltis_lteis_neqis_not_ilikeis_not_inis_not_likeis_not_nullis_nullquery_dfrunrun_sqlselectselect_by_idselect_dfselect_oneTablesExecutionErrorupdateupdate_by_idBuilt-in TablesOverviewDeclaring actionsabstra.servicesOverviewExamplesOverviewSingle Sign-On (SSO)Enviroment VariablesOverviewUsing Abstra LoggerOverviewExamplessend_emailUI PreferencesRetrieving Project URLRetrieving Run IDExternal DebuggerSetting up CIEditor doesn't startUsing Legacy Threads
Cloud Console

Pages

Abstra Pages are Python-powered custom HTML interfaces. Unlike Forms (which use a widget-based UI), Pages give you full control over the HTML, CSS, and JavaScript while keeping the backend logic in Python.

Pages are the recommended choice for dashboards, data visualizations, interactive tools, and any interface that benefits from custom styling. New projects are built with the Abstra Design System: ab-* Web Components (layout, tables, filters, forms, dialogs, charts) themed with --ab-* CSS tokens. Projects that already use Tailwind or their own CSS keep it, and can add ab-* components next to it.

Treasury Dashboard Example

Abstra Design System​

Every example in this documentation is built with the Abstra Design System. Two tags load it, once, in the base template:

<link rel="stylesheet" href="https://design.abstra.io/v0/abstra-ds.css"> <script type="module" src="https://design.abstra.io/v0/abstra-ds.js"></script>

It gives a Page what it would otherwise build by hand:

NeedComponents
App shell, navigation, signed-in user, page titleab-app, ab-page-header, ab-menu
Layout and spacingab-stack, ab-grid, ab-cluster, ab-split, ab-card
Dataab-table (server pagination, sorting, CSV export), ab-kpi, ab-chart, ab-badge
Forms and filtersab-input, ab-select, ab-date-range, ab-filter-bar, ab-upload
Feedbackab-alert, ab-empty, ab-skeleton, ab-progress, ab-result, abToast(), abConfirm()
  • Theme with tokens, not CSS. --ab-accent alone themes every component; the other --ab-* tokens are in Theming. New projects don't need Tailwind or page CSS.
  • Data goes in as properties or JSON, never as HTML. From JavaScript, table.rows = await get_orders(); from a Jinja fragment, rows='{{ rows | tojson }}'. Components render values as text and format currency, date and percent themselves, so Python returns plain numbers and ISO dates.
  • htmx works out of the box. ab-* fields are native form fields, component events (ab-page-change, ab-sort-change, ab-filter-change) are valid hx-triggers that send their values, the triggering ab-button shows a loading state, hx-confirm opens the design system dialog, and HTTP errors become a toast.
  • Start from a recipe. Page recipes are complete pages (template + Python) for an overview, a work queue, a report, a form, an admin table and a record page.
  • Existing projects keep their styling. A project that already uses Tailwind or its own CSS can add ab-* components next to it: load only abstra-ds.js (the CSS file restyles plain HTML) and map its accent color to --ab-accent.

AI agents building Pages can read the whole API from design.abstra.io/llms.txt.

How it works​

A Page is a Python script that:

  1. Registers functions using @register_function — call them over plain HTTP with htmx, or through the generated async JavaScript functions
  2. Defines a __render__ function — a special function that returns the HTML string served to the browser

Use htmx for HTML fragments (CRUD, tables, forms and progress) and JavaScript RPC for structured data (charts, browser state, downloads and media). Both call the same registered Python functions; no library is injected automatically.

For htmx, set up the shared base template once. A page then needs only a function returning a fragment and markup selecting where it goes:

from abstra.pages import register_function from lib_jinja import render_template @register_function def get_message(): return "<ab-alert tone=\"ok\">Hello from Python!</ab-alert>" @register_function def __render__(): return render_template("hello.html", title="Hello")
{% extends "base.html" %} {% block body %} <ab-page-header heading="Hello"> <ab-button slot="actions" hx-get="_fn/message" hx-target="#output">Refresh</ab-button> </ab-page-header> <div id="output" hx-get="_fn/message" hx-trigger="load"><ab-skeleton></ab-skeleton></div> {% endblock %}

When the browser needs a JSON object instead of markup, keep the generated JS function:

from abstra.pages import register_function @register_function def get_data(): return {"message": "Hello from Python!"} @register_function def __render__(): return """ <link rel="stylesheet" href="https://design.abstra.io/v0/abstra-ds.css"> <script type="module" src="https://design.abstra.io/v0/abstra-ds.js"></script> <ab-stack gap="4"> <ab-title level="2" id="output">Loading...</ab-title> <ab-button onclick="load()">Refresh</ab-button> </ab-stack> <script> async function load() { const data = await get_data(); document.getElementById("output").textContent = data.message; } load(); </script> """

JavaScript RPC request flow​

  • GET requests call __render__() and return the HTML with auto-generated JS function stubs
  • POST requests call the specified registered function and return JSON
  • Generator functions are streamed as NDJSON — the JS stub becomes an async function* (async generator)

The __render__ function is not exposed as a JavaScript function — it can only be called by the server on GET requests.

Plain HTTP function protocol​

Call /_page/<path>/_fn/<name> or /_page-home/_fn/<name>. Nested pages work too (/_page/a/b/_fn/rows). Use _fn/rows in htmx attributes and resolve it against the document's <base href> with the base-template hook below. The public navigation URL is not the function endpoint.

HTTP methodRegistered Python function for _fn/rows
GET, HEADget_rows
POSTpost_rows
PUTput_rows
PATCHpatch_rows
DELETEdelete_rows

Do not repeat the verb in the URL: _fn/get_rows and _fn/post_rows return 404 in every method, with a hint when there is a valid suffix. Names starting with _ are also rejected. Missing registered functions return 404; unsupported methods return 405 with Allow. Keep get_* functions read-only: link previews and crawlers may issue GET or HEAD. There is no Python methods= option.

The protocol is HTTP, not htmx-specific. fetch and other frameworks can use it too. Accept selects the format, not HX-Request:

AcceptResponse
text/htmlString → 200 HTML fragment; None → 204, no swap; other types → 406
text/event-streamGenerator → SSE, one message per yield
Other / absentExisting SDK JSON response ({"result": ...}), or NDJSON for a generator

For an HTML caller, SDK exceptions keep their status (usually 500) and become plain text. Authentication is still enforced: a protected page without a token returns 401. The base-template hook sends the existing abstra-auth-token meta as Authorization: Bearer … and abstra-execution-id as X-Page-Execution-Id. Fragments do not receive a document prelude or a traceparent meta. Responses vary on Accept.

Parameters and v1 limits​

  • Query parameters plus JSON/urlencoded body become keyword arguments; the body wins collisions. Only POST, PUT and PATCH read a body. GET, HEAD and DELETE use the query.
  • Query/urlencoded values are text. Convert inside Python (int(page), Decimal(amount)); type annotations do not perform conversion. JSON preserves its supplied types.
  • Use name[] for lists, even with one value. Plain tags=a is a string. An unchecked checkbox is absent; optional parameters need defaults (notify="").
  • Multipart files arrive as dictionaries of bytes; ordinary multipart fields are decoded with json.loads when possible. id=7 can become an integer, and numeric postal codes can lose leading zeroes. Convert deliberately; use JSON/RPC for structured values. Multipart query values are available through get_query_params(), not merged into function arguments.
  • v1 has no custom status/header helpers or HX-Trigger. Return HTML validation messages with 200, and use hx-swap-oob to update other parts of the page. Do not turn invalid user input into an exception just to display a field error.
  • htmx 4 attributes are not inherited unless they have the :inherited suffix. Put hx-target, hx-disable, etc. on each request element, or explicitly opt in.
  • hx-vals accepts scalar values only. Arrays stringify to "1,2"; objects to "[object Object]". Use named form inputs for lists, or JS RPC for structured data.
  • @register_function(cache=N) caches the generated JS result only, not /_fn requests.

SSE with htmx​

Load htmx 4.0.0 and its dist/ext/hx-sse.js extension explicitly (both scripts and SRI hashes are in the base template). Prefer a finite hx-get or hx-post request: each unnamed message swaps its HTML, and the connection ends with the generator without reconnecting.

import time from abstra.pages import register_function @register_function def post_progress(): for percent in (25, 50, 75, 100): time.sleep(1) yield f'<ab-progress value="{percent}" status="active"></ab-progress>' yield {"event": "done", "data": "Complete"}
<ab-button hx-post="_fn/progress" hx-target="#progress" hx-on:done="abToast(event.detail.data)" hx-on:error="abToast(event.detail.data, { tone: 'danger' })"> Start </ab-button> <div id="progress" role="status"></div>

A yielded string becomes SSE data: (multiple lines are preserved). A dict containing only event, data, id and/or retry, with event or data, is reserved for SSE fields; other values become JSON in data:. Named events dispatch DOM events rather than swapping HTML. A generator exception becomes event: error; read its message with event.detail.data. Transport failures use the separate htmx:sse:error event.

Only use hx-sse:connect for an intentional persistent GET connection. It reconnects when the stream ends. To stop normally, finish get_progress with yield {"event": "done", "data": ""} and use hx-sse:close="done":

<div hx-sse:connect="_fn/progress" hx-sse:close="done"></div>

Streaming structured data with JavaScript​

If a registered function is a Python generator (uses yield), it will be streamed to the browser chunk by chunk. You can consume the stream in three ways:

from abstra.files import get_persistent_dir from abstra.pages import register_function @register_function def read_large_file(): with open(get_persistent_dir() / "big_data.csv") as f: for line in f: yield line @register_function def __render__(): return """ <link rel="stylesheet" href="https://design.abstra.io/v0/abstra-ds.css"> <script type="module" src="https://design.abstra.io/v0/abstra-ds.js"></script> <ab-code id="output" label="big_data.csv" max-height="60vh"></ab-code> <script> // Option 1: .forEach — works in any <script> tag let text = ""; read_large_file().forEach(line => { text += line; document.getElementById("output").code = text; // ab-code shows it as text }); </script> """

You can also use for await...of inside a <script type="module"> for top-level await:

<script type="module"> const el = document.getElementById("output"); let text = ""; for await (const line of read_large_file()) { text += line; el.code = text; } </script>

Or collect everything at once with await:

const lines = await read_large_file(); // array of all chunks

This is useful for:

  • Large files — stream data without loading it entirely into memory
  • Progress updates — yield status messages as a long task progresses
  • Real-time logs — stream log lines to the browser as they happen

Creating a Page​

Drag the Pages icon from the workflow toolbar onto the canvas, or press the U keyboard shortcut.

Workflow toolbar with Pages icon

The page appears in the workflow canvas alongside forms, hooks, and other stages.

Workflow canvas with page stages

Running and testing​

Click on a page node and select Run to preview it in the editor. The PageTester shows the rendered HTML in an iframe with a toolbar for URL preview, query params, fullscreen, and tasks.

PageTester in the editor

Pages vs Forms​

FeaturePagesForms
UI controlFull HTML/CSS/JSWidget-based (pre-built components)
StylingAbstra Design System (recommended) or any CSSAbstra theme system
Best forDashboards, custom UIs, data vizStep-by-step data collection
Backend calls@register_function → HTTP/htmx or async JSWidget events → Python state
ValidationCustom (in JS or Python)Built-in widget validation
File uploadsRecommended — file bytes arrive in-memoryBuilt-in FileInput widget; avoid reading files across steps
Multi-step flowsCustom (SPA-style)Built-in with run() steps

Rule of thumb: Prefer Pages for new interfaces — data display, custom UIs, and any flow that uploads files. Use Forms when a widget-based wizard for structured input is all you need; for flows that upload and process files, prefer Pages.

Static files​

Use register_static to serve local files (JS, CSS, images, etc.) from your page. It returns a signed URL, and each call returns a fresh one, so browsers always get the latest version without any manual cache busting.

from abstra.pages import register_function, register_static @register_function def __render__(): js_url = register_static("code.js") css_url = register_static("styles.css") logo_url = register_static("logo.png") return f""" <link rel="stylesheet" href="https://design.abstra.io/v0/abstra-ds.css"> <script type="module" src="https://design.abstra.io/v0/abstra-ds.js"></script> <link rel="stylesheet" href="{css_url}"> <ab-app brand-name="My Page" brand-logo="{logo_url}"> <ab-page-header heading="My Page"></ab-page-header> </ab-app> <script src="{js_url}"></script> """

register_static accepts str or any os.PathLike (e.g., pathlib.Path). The returned URL is a subpath of the page itself and carries a signed token query parameter, valid for 24 hours (e.g., /_page/my-page/code.js?token=eyJ...). Call it inside __render__ so every page load gets a fresh URL.

The URL already has a query string. Don't append a second ? (e.g., f"{register_static('styles.css')}?v=2"): it corrupts the token, and the file loads in the editor preview but fails once published. You don't need a version parameter; if you need other query parameters, append them with &.

This is useful for:

  • Custom JavaScript — keep JS in .js files with proper syntax highlighting
  • CSS stylesheets — serve local CSS without CDN dependencies (with the design system, only :root { --ab-*: … } tokens)
  • Images and assets — reference local images directly from your HTML

Project structure best practices​

For anything beyond a simple page, keep each concern in its own file: HTML templates, CSS styles, JavaScript logic, and Python backend — each separate. Extract shared UI patterns into reusable helper functions.

my-project/ ├── page_dashboard.py # Python logic only ├── page_settings.py ├── templates/ │ ├── base.html # Shared HTML shell │ ├── dashboard.html # Page-specific markup │ └── settings.html ├── static/ │ └── js/ │ ├── common.js # Shared utilities (formatting, etc.) │ └── dashboard.js # Page-specific behavior └── lib_jinja.py # Jinja2 rendering helper

Jinja2 rendering helper​

Create a small helper to load and render templates:

# lib_jinja.py import os from jinja2 import Environment, FileSystemLoader _TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") _env = Environment(loader=FileSystemLoader(_TEMPLATES_DIR), autoescape=True) def render_template(template_name: str, **context) -> str: template = _env.get_template(template_name) return template.render(**context)

Base template​

The base template links shared CSS and JS files, and defines blocks for page-specific assets:

{# templates/base.html #} <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{ title | default("My App") }}</title> <link rel="stylesheet" href="https://design.abstra.io/v0/abstra-ds.css"> <script type="module" src="https://design.abstra.io/v0/abstra-ds.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js" integrity="sha384-BvJpBiO8Kh31EqtJe5DRIeWrHWnCGkwytKs9NKFi86Hhw96dEqdEMzZDeK9iEGTc" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.js" integrity="sha384-O9jqsFi4oKUTwu9V0DTXGP+lKOx/8Y4exCbAo4C259mlwITM1xPC3s3Nb4VPQvzj" crossorigin="anonymous"></script> <script> document.addEventListener("htmx:config:request", (e) => { const req = e.detail.ctx.request; if (!req.action.startsWith("_fn/")) return; const [path, qs] = req.action.split("?"); const url = new URL(document.baseURI); url.pathname = url.pathname.replace(/\/$/, "") + "/" + path; url.search = qs ? "?" + qs : ""; req.action = url.href; req.mode = "cors"; const meta = (n) => document.querySelector(`meta[name="${n}"]`)?.content; const token = meta("abstra-auth-token"); if (token) req.headers["Authorization"] = "Bearer " + token; const exec = meta("abstra-execution-id"); if (exec) req.headers["X-Page-Execution-Id"] = exec; }); // Keep the current content on HTTP errors; the design system shows an error toast. document.addEventListener("htmx:after:request", (e) => { if (e.detail.ctx.response.status >= 400) e.preventDefault(); }); </script> <style>:root { --ab-accent: #2f55d4; }</style> {% if base_css_url %}<link rel="stylesheet" href="{{ base_css_url }}">{% endif %} {% block head_extra %}{% endblock %} </head> <body> <ab-app brand-name="My App" user-email="{{ user.email if user else '' }}"> {% block body %}{% endblock %} </ab-app> {% if common_js_url %}<script src="{{ common_js_url }}"></script>{% endif %} {% block scripts %}{% endblock %} </body> </html>

The first hook above is the shared Page endpoint/auth integration: install it once, not once per button. It rewrites only _fn/ actions, so credentials are not attached to unrelated requests. Keep the platform-provided <base href>; do not override it with your public navigation path. The error handler uses htmx 4's response status and cancels htmx:after:request on HTTP errors, keeping plain-text errors out of HTML swaps (htmx 4 swaps 4xx/5xx by default unless you cancel or configure it). The design system shows an error toast for HTTP and network failures, turns the triggering ab-button into a loading state, and opens its own dialog for hx-confirm. --ab-accent is the whole theme: see Tema for the other tokens.

Pass base_css_url=register_static("static/css/base.css") and common_js_url=register_static("static/js/common.js") from __render__ when using those optional assets. Their tags are omitted when the URLs are not supplied. Fragments must render standalone templates without {% extends "base.html" %}; otherwise every swap would duplicate scripts and the shell. Enable Jinja autoescape and do not mark user input safe.

Page template — HTML only​

Templates contain only markup — no inline <script> or <style> blocks. JS and CSS live in their own files.

{# templates/dashboard.html #} {% extends "base.html" %} {% block body %} <ab-page-header heading="Dashboard"></ab-page-header> <ab-kpi id="stats" label="Items" loading></ab-kpi> {% endblock %} {% block scripts %} <script src="/static/js/dashboard.js"></script> {% endblock %}

JavaScript — own file​

// static/js/dashboard.js async function loadStats() { const kpi = document.getElementById("stats"); const data = await get_stats(); kpi.value = data.total; // components take data as properties, never as HTML strings kpi.loading = false; } loadStats();

Shared JS utilities​

// static/js/common.js function formatDate(isoDate) { return new Date(isoDate + "T00:00").toLocaleDateString("pt-BR"); }

Toasts, confirmations and error messages don't need helpers: the design system provides abToast("Salvo"), await abConfirm({ title, message, tone: "danger" }) and <ab-alert>.

Page Python file — logic only​

# page_dashboard.py from abstra.pages import register_function from lib_jinja import render_template @register_function def get_stats(): return {"total": 42} @register_function def __render__(): return render_template("dashboard.html", title="Dashboard")

Shared UI​

The header, the signed-in user, navigation, page titles, toasts and dialogs are design system components, so there is no HTML to build in Python: ab-app in base.html is the shell, each template starts with <ab-page-header heading="…">, and JS calls abToast(...). Extract a Jinja macro or include only for markup that repeats across your own Pages. Keep Python helpers out of markup: return data, and let templates render it.

This approach keeps each language in its own file: Python for logic, HTML for structure, JS for behavior. It makes templates easy to edit visually, enables syntax highlighting everywhere, and lets you share patterns across pages without duplicating code.

Sharing layout across multiple pages​

A common pattern is an app where several Pages share a navbar or sidebar — for example, a financial dashboard with Indicators, Cash Flow, and Default rate sections, each one its own Page.

It can be tempting to build a "main" Page that holds the sidebar and embeds the active section in an <iframe>. Avoid this: every Page route already renders inside the project's own chrome, so nesting one Page inside another shows the chrome twice — duplicated navbars and breadcrumbs, broken in-page navigation, and a split auth/scroll context.

The same base.html from Project structure best practices can host the shared sidebar. Each Page extends it and renders its own content block, so navigation between sections becomes plain links to each Page's clean public path (e.g. <a href="/cash-flow">) and the sidebar appears exactly once. Never link to /_page/...: that is an internal RPC route that serves the bare page without the app shell, breaking navigation and auth.

ab-app shows its navigation as a sidebar with the sidebar attribute; one ab-menu holds the sections and marks the current one from active:

{# templates/base.html — body with the shared sidebar #} <body> <ab-app brand-name="Finance" sidebar user-email="{{ user.email if user else '' }}"> <ab-menu slot="nav" mode="vertical" value="/{{ active }}"> <ab-menu-item href="/dashboard" icon="squares-four">Dashboard</ab-menu-item> <ab-menu-item href="/cash-flow" icon="chart-line-up">Cash Flow</ab-menu-item> <ab-menu-item href="/default-rate" icon="percent">Default rate</ab-menu-item> </ab-menu> {% block body %}{% endblock %} </ab-app> {% block scripts %}{% endblock %} </body>

Cash Flow page template​

Each section's template extends the base and fills the body block — same pattern as dashboard.html above:

{# templates/cash_flow.html #} {% extends "base.html" %} {% block body %} <ab-page-header heading="Cash Flow"></ab-page-header> <ab-chart id="cash-flow-chart" type="area" loading></ab-chart> {% endblock %}

Cash Flow page Python file​

Render the template marking this section as active:

# page_cash_flow.py from abstra.pages import register_function from lib_jinja import render_template @register_function def __render__(): return render_template("cash_flow.html", title="Cash Flow", active="cash-flow")

Repeat the template + Python file pair for each section (page_indicators.py, page_default_rate.py), passing the matching active value. The sidebar lives in one place; each Page is its own route.

Keeping page state in the URL​

A Page runs inside an isolated frame, so history.pushState() and history.replaceState() called from the page never reach the address bar. There are two ways to change the URL from a Page:

  • A link to the same Page with a new query (<a href="?status=pending">, as in the orders table example) re-renders the Page: __render__ runs again and reads the new values with get_query_params().
  • abstra.replaceUrl(), from the same window.abstra browser API as abstra.login(), only rewrites the query string and hash in the address bar. The Page is not re-rendered, keeps its JavaScript state, and no history entry is added. Use it to reflect what the user is looking at (an open item, a selected filter), so that reloading or sharing the URL brings them back to the same place.
import html from abstra.pages import register_function, get_query_params @register_function def __render__(): open_run = html.escape(get_query_params().get("run", "")) return f'<ab-text id="detail" data-run="{open_run}"></ab-text>' + """ <ab-button onclick="openRun('1')">Run 1</ab-button> <ab-button onclick="openRun('2')">Run 2</ab-button> <script> const detail = document.getElementById("detail"); function openRun(id) { detail.textContent = "Run " + id; abstra.replaceUrl({ search: "?run=" + id }); } if (detail.dataset.run) detail.textContent = "Run " + detail.dataset.run; </script> """

abstra.replaceUrl() takes an object with:

  • search — the new query string, such as "?run=42". It replaces the whole query; "" or leaving it out removes it.
  • hash — the new fragment, such as "#logs". "" or leaving it out removes it.

The path always stays the one of the current Page.

Registered functions called after abstra.replaceUrl() still see, in get_query_params(), the query the Page was rendered with. Pass the current state to them as arguments instead.