Every tool, in one place
This page is the practical reference for each tool in the sidebar of your Archestack environment. Each section covers what the tool does, when you'd reach for it, the steps for the most common workflows, and the gotchas that catch new users. The final section (Script-callable APIs) documents the exact surface area scripts can call.
Schema Designer
A visual editor for the database schema, tables, columns, types, relationships, indexes, that auto-saves to a JSON document. The schema is the source of truth for your application; no other tool sees a column until you add it here and deploy.
Common workflow: in the toolbar, click Add Table (icon button). A
dialog titled "Create New Table" opens; type a name (the helper text reminds you "Lowercase
with underscores recommended"), click Create. The table appears on the canvas with
an auto-generated id SERIAL primary key. Select the table, the right panel
opens on the Columns tab. Add columns from the dashed-border "Add column" box at the
bottom (name field, Type dropdown, click Add Column). Click each column to expand
and adjust toggles (PK, NULL, UQ), Length, and
Default value.
Column types available in the Type dropdown: SERIAL, BIGSERIAL, INTEGER, BIGINT, SMALLINT, DECIMAL, NUMERIC, REAL, DOUBLE PRECISION, VARCHAR, CHAR, TEXT, BOOLEAN, DATE, TIME, TIMESTAMP, TIMESTAMPTZ, UUID, JSON, JSONB, BYTEA, INET, CIDR, MACADDR, MONEY, INTERVAL, plus geometric and array types. Length is only relevant for VARCHAR and CHAR.
Foreign keys live in the Relations tab of the right panel (not "Relationships"). Scroll to "Add relationship": pick the FK column on the current table, the relation type (One-to-One / One-to-Many / Many-to-Many), the references table, the references column, and click Add Relationship. Each relationship has On Delete and On Update dropdowns (CASCADE / SET NULL / SET DEFAULT / RESTRICT / NO ACTION).
Auto-save: the indicator in the bottom-left flicks between "Auto saving…" and "Saved". There's also a Save button in the toolbar (only enabled when there are pending changes) for explicit saves.
Other toolbar buttons: Add Group (visual containers for related tables, properties panel titled "Group"), Add Text (free-form annotations, properties panel titled "Text Annotation"), zoom controls, package filter, table search.
Gotchas: renaming a column produces a destructive plan (drop + add), edit
the SQL on the deployment's Generated SQL tab to use RENAME COLUMN if you want
to preserve data. Adding a NOT NULL column to a non-empty table without a default fails,
make it nullable, backfill, then alter. The Deploy toolbar button takes you to a
deployment config page, not directly into a deploy.
Database Deployments
Lists every deployment with its status (Draft / Executing / Succeeded / Failed) and runs
new ones. Route: /database-deployments/overview.
Top-right buttons: Refresh (reload the list), New Deployment (create a new draft).
From Schema Designer: the green Deploy rocket-icon button in the
Schema Designer toolbar takes you to /database-deployments/configure/new, a fresh
deployment configuration page.
Deployment configuration page: two tabs.
- Configuration - pre/post-deployment script editors (PostgreSQL) with a Test Run button each. Pre-script runs before schema changes; post-script after. Useful for backfills, index rebuilds, or staging-area cleanup. Plus a Force Deploy (Allow Data Loss) toggle for destructive operations.
- Generated SQL - shows the migration the platform produced. The SQL is editable. A Regenerate button (refresh icon) re-runs the diff if you've made schema changes since opening this page.
Top buttons: Back, Save (saves the configuration as a Draft), Deploy (runs the deployment immediately). Deploy is what you click last.
Gotchas: deployments aren't automatically wrapped in a transaction, a
failure halfway through leaves the database in a partial state. For risky migrations, wrap
your statements in BEGIN; … COMMIT; in the pre-script. The Force Deploy
toggle skips safety checks; use it deliberately.
Business Entities
Curated views built on a master table plus joins from related tables. Pages bind to Business Entities, never to raw tables. See Concepts → Business Entities for the why.
Common workflow: click Create. The full-page editor opens with tabs Visual, JSON, Events. Set Entity Name, Master Table (Autocomplete), and Label Column (the column whose value represents a record in pickers). Save, the BE auto-generates a list of native columns from the master table.
Adding joins: scroll to the Join Configurator card and click Add Join. Each join opens as an Accordion with: Join Type (INNER / LEFT / RIGHT), From Table, To Table (Autocomplete grouped by Database Tables / Third Party), From Column, To Column, optional Type Cast dropdowns, and a column picker for which target columns to surface.
Aggregations: on a join, toggle Aggregate Mode. For each picked column you then choose an Aggregate Function: COUNT / SUM / AVG / MIN / MAX / COUNT DISTINCT. "Number of open contacts on this Company" is the canonical example.
Run Preview: the editor has a Run Preview button (with a play icon) that executes the full join configuration and shows real rows in a data grid. If a join column comes back empty, the column wasn't ticked in the join's column picker (or the relationship is misconfigured).
Multiple BEs per master table: nothing stops you from having
customer (sales view) and customer (support view) on the same
master. Different pages, different audience-appropriate columns, same underlying row.
Gotchas: joined and aggregated columns are read-only. To edit the joined label, navigate to the source record. Aggregated columns are recomputed on every query, fine for hundreds of rows, slow on millions.
Page Editor
Configures the runtime UI by binding a Page to a Business Entity. The platform auto-generates sections based on the BE's columns; you tune the layout from there. There is no separate "template picker", every page starts from the same generated baseline and you customise it.
Common workflow: click Create. Fill Page Name,
Page Route (e.g. /companies), pick the Business Entity
(Autocomplete). The page opens in the editor with tabs Visual, Overview,
Create, Entities, Events, JSON. Tweak the auto-generated
layout, then flip the Published Switch in the top header to ON.
Field widget types (the Type Select on each field): Text,
Textarea, Number, Date, Select, Checkbox, Email. Use Select for foreign-key
fields and set the field's Entity autocomplete to the referenced BE so users pick
by label.
Tabs in the detail form: click Add Tab in the Visual tab. Inside a
tab you can add a RelatedGrid section bound to another BE filtered by the current
record's PK. Classic example: a Company page with "Contacts" and "Deals" tabs, each filtered
by company_id = current company's id.
Action buttons on the detail form's Header inspector. Click Add in the Actions section. Each action has Label, Icon (Save / Delete / Add / Refresh / Download / Bolt / None), Variant (Contained / Outlined / Text), Color (Primary / Secondary / Error / Success / Warning / Info), and Steps (an ordered list of: Save Record, Delete Record, Navigate, Business Events, Custom). To run a Business Event from a button, set the step type to Business Events and pick the event by name.
Publishing: the Published Switch in the top header is the only publish control. ON = the page appears under Published Pages in the sidebar and end users navigating to its route see it. OFF = draft (only you in the editor see your changes).
Gotchas: the auto-generated columns on a fresh Page mirror the BE, if the BE changes (you add a join), the Page won't auto-pick up new columns. Reload the page in the editor or add the column manually to the section. There is currently no "Generate PDF" action step type, to wire a PDF to a page button, expose the PDF via a Script Module that calls the REST endpoint and use a Custom step (or call the endpoint directly from a Frontend Template).
Business Events
Rules that fire on data changes (or on a schedule, or manually) and run actions when conditions are met. The platform's "when X happens, do Y" mechanism. See Concepts → Business Events for the model.
Common workflow: click Create. Set Rule Name, toggle Enabled. Pick Business Entity. Tick one or more Triggers: BeforeCreate / BeforeUpdate / AfterCreate / AfterUpdate / BeforeDelete / OnSchedule / Manual / InitialValue. Build the condition tree (AND/OR groups). Add one or more actions in the FlowCanvas. Save.
Action types (RuleActionKind):
- Execute Script - run a C# script. The most flexible action; reach for it whenever you want to set a field, do a calculation, call an API, anything custom. The script gets
Entity(mutate it on Before* triggers to change the in-flight write),OldEntity,Log,Db,Modules. - Validate - a script that returns
bool.truemeans validation fails and the save is blocked with the configured error message. Optionally highlights specific columns via errorColumns. - Block Operation - hard-stops the operation with a configured message. No script.
- Create Entity - inserts a record into another BE. Field values support template expressions like
{{ Entity.column_name }}and{{ now() }}. - Update Entity - updates records in another BE matching a condition filter. Same template expressions.
- Delete Entity - deletes records matching a condition. Refuses to fire without a condition (safety).
- Send Email / Send Webhook / Publish Event - defined in the schema as future enhancements; currently logged and skipped.
"Set a field" pattern: there is no discrete Set Field action. The
way to set a field is an Execute Script on a Before* trigger that mutates
Entity. Example: Entity.title = ((string)Entity.title)?.Trim();. The platform
persists the modified Entity as part of the in-flight write, no extra query, no recursion
risk.
Run Simulation: the trigger editor's Simulate tab has a record
picker and a Run Simulation button. The platform runs your conditions and actions
against the chosen record without persisting changes: writes are checked and recorded, never
executed, a script's writes through Db and Files included, so nothing
reaches the database or the workspace's files and no further business event fires. Mail and
notifications are not sent. Anything else a script does itself is real: HTTP calls,
System.IO, and anything a NuGet package does, such as a
connection to another database. The output panel shows which conditions matched and
what each action would have done. Use this to catch misconfigurations before live data hits them.
Template expressions in action configuration use {{ … }}
braces. The actual tokens: {{ Entity.column }}, {{ OldEntity.column }},
{{ now() }} / {{ getdate() }}, {{ today() }},
{{ guid() }} / {{ newid() }}, {{ year() }},
{{ month() }}, {{ day() }}, {{ timestamp() }},
aggregates like {{ SUM(column) }} within join contexts, and post-substitution
arithmetic like {{ Entity.quantity * Entity.price }}. There is
no {{ user.email }} token.
Condition operators: Equals, NotEquals, GreaterThan, LessThan, GreaterThanOrEqual, LessThanOrEqual, Like (= ILIKE), Contains (= ILIKE %value%), IsNull, IsNotNull, In (= ANY(...)).
Gotchas: a trigger that mutates the same record it watches will re-fire
itself if you use Update Entity; use Before Update + Execute Script
+ Entity.field = … instead. Two triggers on the same event fire in priority
order. Disabled events still appear in the list, the Enabled toggle is separate
from the trigger config.
Script Modules
Reusable C# scripts compiled at runtime by Roslyn. Take parameters, query the database via
the Db helper, return a value. Callable from Business Events, Scheduled Events,
and directly from the front end.
Common workflow: click Create. Editor opens with tabs Edit,
Parameters, Test, JSON. Name the module (PascalCase, e.g.
RecalcOpenRevenue). In the Parameters tab, click Add per
parameter (Name, Type, Required, Default Value). Type options: string, int, decimal, double,
bool, DateTime. Back to the Edit tab, write C#. IntelliSense is on.
Test panel: the Test tab shows your parameter inputs on the left and a Run button (PlayArrow icon). Click Run; the right side shows success or error indicator, output log, and the return value (JSON-formatted).
The Db helper, see the full reference in Script-callable APIs below.
Calling from a Business Event: add an Execute Script action whose body calls the module:
// The module is a method; its name and parameters are checked when the script compiles
await Modules.RecalcCompanyOpenDealValue(company_id: (int)Entity.company_id);
// Calling it by name still works
await Modules.CallAsync("RecalcCompanyOpenDealValue", new Dictionary<string, object?> {
["company_id"] = Entity.company_id
}); Calling from a Scheduled Event: create an event with the
On Schedule trigger, set the cron, add an Execute Script action with the
same Modules.CallAsync body.
Logging: every invocation writes an entry to Event Logs with execution time, parameters, and result (or error).
Gotchas: always await Db calls, forgetting compiles but
returns a Task. Returns are object-passed (not JSON-serialised by the framework); for
front-end consumption, return anonymous objects with primitive fields. Suggestion-on-commit-character
is intentionally disabled in the editor; use Tab to accept.
Shared Code
Code that more than one script or page needs, kept in files of its own. A file is named by its
path, such as Sales/OrderSummary.cs or components/StatusChip.tsx, and its
extension says what it is: .cs is C# every script can use, while .tsx,
.ts, .jsx and .js are modules that pages, frontend templates and
layout Code blocks import. Shared files belong to packages like any other artifact.
Common workflow: in the sidebar, under Logic, open Shared Code and click New file. Type the path; the editor starts the file with an example of its kind. Write the code and click Save.
C# files are ordinary C#: namespaces, records, classes and static helpers. Every script
(script modules, business events, scheduled events, PDF data scripts and tests) can use their public
types. A type without a namespace is available by its name; one in a namespace needs a
using in the script, or a global using in any shared file, which then applies
to every script. A shared file sees the typed data model (Order,
WorkspaceDb) but not a script's globals, so a helper that needs Db or
Log takes it as an argument.
// Shared code: Sales/OrderSummary.cs, ordinary C# every script can use
namespace Sales;
/// <summary>One order as the invoices and the dashboard report it.</summary>
public record OrderSummary(int Id, string Number, decimal Total);
public static class Vat
{
public static decimal Add(decimal amount, decimal rate = 0.21m) =>
Math.Round(amount * (1 + rate), 2);
} // A script module using it
using Sales;
var open = await Db.Order.Where(o => o.Status == "Open").ToListAsync();
return open.Select(o => new OrderSummary(o.Id, o.Number, Vat.Add(o.Total ?? 0))).ToList(); A saved change reaches every script on its next run. The editor checks a shared file together with the other shared files, and Go to Definition on a shared type in a script opens the file that declares it. When the shared code does not compile, the scripts that use it fail and name the file that is wrong; scripts that do not use it keep running. A table change that would break a shared file is reported with the scripts it breaks, before the deployment runs.
Modules are imported from @/ followed by their path without the
extension: components/StatusChip.tsx is
import { StatusChip } from "@/components/StatusChip". A page can also hold files
of its own beside its views, added with + above its code editor and imported as
./OrderRow. A module sees the same globals page code does and exports what others use. A
view that imports may end by exporting its component as the default instead of with
return. Code without an import or export runs exactly as it
always did.
// Shared code: components/StatusChip.tsx, a module pages import
export function StatusChip({ status }: { status: string }) {
const color = status === "Open" ? "warning" : status === "Paid" ? "success" : "default";
return <Chip size="small" color={color} label={status} />;
} // A page's overview, with a file of its own, OrderRow.tsx, beside it
import { StatusChip } from "@/components/StatusChip";
import { OrderRow } from "./OrderRow";
export default function Orders() {
const [rows, setRows] = useState([]);
useEffect(() => {
backend.getBusinessEntityData("Order").then((r) => setRows(r.rows.items));
}, []);
return (
<Stack spacing={1}>
{rows.map((row) => (
<OrderRow key={row.id} row={row}>
<StatusChip status={row.orders_status} />
</OrderRow>
))}
</Stack>
);
}
The packages code can import are the ones the app already carries, and an import is the same object
the page's globals hold: react, @mui/material,
@mui/x-date-pickers, dayjs, lucide-react, three,
@react-three/fiber and @react-three/drei. Any other package is not there.
Publishing: the published app reads the shared modules as the current release pinned them, so an edit reaches end users with the next publish, as a page does. A visitor who is not signed in receives only the modules that public pages and public layouts import.
Gotchas: the path is a file's identity in every workspace, and imports name it, so
renaming a file breaks what imports it until they follow. Paths use letters, digits, dots, underscores
and hyphens. The Archestack namespace is reserved. An alias in a global using
(global using Money = decimal;) is not passed on to scripts.
NuGet Packages
Libraries from NuGet for the workspace's C# scripts: a driver for another database, a CSV or Excel
reader, a client for a service. Every script can use them (script modules, business event scripts,
scheduled events, PDF data scripts, test scripts and code checks), and so can the shared
.cs files. The script editor and VS Code complete their types. A workspace that adds
none runs its scripts exactly as before.
Common workflow: in the sidebar, under Logic, open NuGet Packages and click Add NuGet package. Search for it, or type its exact id when the source cannot be searched, and pick an exact version: the latest stable one is preselected, and Include prerelease versions lists the others. Choose what it Belongs to: one of the workspace's packages, or Workspace only. Add downloads it with its dependencies on the server first. When that fails, the dialog says why and nothing is saved; when it works, it shows the namespaces it brings.
In a package: a NuGet package that belongs to a package travels with it, in its
export and in what a pipeline deploys, and installing a package lists the NuGet packages it adds or
raises before anything changes. In a repository they are
packages/<package>/nuget.json, written only when the package declares any, and VS
Code checks the file as you type. A push that changes the file downloads them first, and is refused,
changing nothing, when they cannot be restored.
{
"packages": [
{ "id": "CsvHelper", "version": "33.0.1" },
{ "id": "Dapper", "version": "2.1.35" },
{ "id": "MySqlConnector", "version": "2.4.0" }
]
} Using one: a NuGet package brings types, never a global, and nothing is imported
for you. Write a using for its namespace, which is often not the id: the page's
Namespaces column shows each NuGet package's namespaces as using lines to copy.
Return plain values from a script, not the library's own objects.
// NuGet package: CsvHelper. Reads a price list an admin uploaded on the Files page.
using System.Globalization;
using System.IO;
using CsvHelper;
var text = await Files.ReadTextAsync("imports/prices.csv");
var csv = new CsvReader(new StringReader(text), CultureInfo.InvariantCulture);
var rows = new List<object>();
try
{
csv.Read();
csv.ReadHeader();
while (csv.Read())
rows.Add(new { Sku = csv.GetField("sku"), Price = csv.GetField<decimal>("price") });
}
finally
{
csv.Dispose();
}
// Plain rows, not CsvHelper objects.
return rows; // NuGet packages: MySqlConnector and Dapper. A script module that lists the open invoices in
// the old ERP's MySQL database. The connection string is a file an admin uploads on the Files
// page of each environment: a password written in a script travels to git, package exports
// and every environment.
using Dapper;
using MySqlConnector;
var connectionString = await Files.ReadTextAsync("config/erp-connection.txt");
var connection = new MySqlConnection(connectionString.Trim());
try
{
await connection.OpenAsync();
var invoices = await connection.QueryAsync(
"SELECT number, customer_number, amount FROM invoices WHERE paid = 0 ORDER BY number");
// Plain values, not driver objects.
return invoices
.Select(i => new { Number = (string)i.number, Customer = (string)i.customer_number, Amount = (decimal)i.amount })
.ToList();
}
finally
{
// A top-level using var does not parse in a script, so the connection is closed here.
await connection.DisposeAsync();
} Versions: a declaration names one exact version, such as 2.1.35, never
a range. When several packages ask for different versions of the same NuGet package, the highest is
used by every script in the workspace. The In use column shows which version that is and
who asked for it.
Whose copy runs: a workspace loads its own copy of each NuGet package, kept apart
from every other workspace on the server, also when the server carries the same library for itself.
.NET itself and the libraries scripts already have, such as System.Text.Json, logging,
EF Core and Npgsql, stay the server's copy. A NuGet package that asks for a newer version of one of
those gets a warning: it runs on the server's copy, and a method only the newer version has fails
when it is called.
Restores: the first script that needs the NuGet packages on a server has that server download them. It waits up to five seconds, and is asked to run again in a moment when the download takes longer. After a change, the workspace restores the new set. Until that works, scripts keep the last set that restored, so a failed restore never takes away NuGet packages that worked. The banner on the page shows the state and a failed restore's errors explained. After a failure the server tries again the first time a script needs the NuGet packages after the time the banner gives, or at once with Restore now. Resolved lists every NuGet package scripts use, dependencies included.
Dry runs and tests: a dry run (the Test tab, Run Simulation)
holds back only what goes through Archestack: writes through Db and
Files are simulated and not saved, and mail and notifications are not sent. A test
run makes its Db writes in its sandbox and holds back the rest the same way. Anything
else the script does itself is real: HTTP calls, System.IO, and anything a NuGet
package does, such as a connection to another database, so a write through a driver happens on
every run. Point such a script at a test database while you try it out.
Gotchas:
- NuGet packages need typed data access, as shared C# does. On a server that turns it off (
Scripting:TypedData:Enabled), the page says so and cannot add one. #r "nuget: Dapper, 2.1.35", the way other script runners load one, does not work here. Add it on the page.- A native library a NuGet package brings loads once per server process, is shared by the workspaces that use the same file, and stays loaded until the server restarts.
- Workspace only NuGet packages are not exported with a package, not in the repository and not deployed by pipelines. Give a NuGet package an app needs to that app's package.
- A top-level
using vardoes not parse in a script, so close a connection yourself, in afinally, as the sample does. - A value read from
Entityisdynamic, and an extension method such as Dapper'sQueryAsynccannot take one as an argument: the script does not compile (CS1973). Cast it first:(string)Entity.customers_number. - NuGet packages that are tools, templates or MSBuild SDKs are refused, and so is one with nothing scripts can compile against on .NET 8, or one that extends the server's own PostgreSQL driver or EF Core. Ids named
OmniCoreorArchestack, or starting with either and a dot, are the platform's.
For operators: NuGet packages are on by default and always off in a trial. The
settings are under Scripting:NuGet, or as environment variables
Scripting__NuGet__Enabled and so on. Every server downloads for itself, so each needs
to reach nuget.org or the feeds configured here, through the proxy in https_proxy,
http_proxy and no_proxy when there is one. Give every server of one
installation the same sources.
| Setting | Default | What it does |
|---|---|---|
Enabled | true | Off: nothing is downloaded and scripts load none. The page cannot add one; declarations an import or a push brings are kept, unused. |
Sources | nuget.org | Where NuGet packages come from, separated by ;: feeds by their v3 service index (the URL ending in index.json), or absolute folders, which are copied from and trusted: the download limits apply to feeds only. nuget.org is used only when neither this nor Feeds names a source. |
Feeds:<name>:Url, Username, Password | None | A named feed, for one that needs credentials. The URL never carries a user name or password. |
Feeds:<name>:Patterns | None | The ids this feed serves, separated by ; (Contoso.*). When any feed has patterns, an id comes from the feed whose pattern it matches, and a feed without patterns serves everything else. |
Feeds:<name>:AllowInsecureConnections | false | Lets a plain http:// feed through. |
Directory | A folder in the local application data | Where this server keeps the NuGet packages it downloaded: one folder per server on its local disk, never a volume servers share. In a container, give it a volume of that server's own, or every new container downloads them again. |
MaxPackageBytes, MaxExtractedBytes | 100 MB, 1 GB | The largest NuGet package a restore downloads, and the most one NuGet package may unpack to. |
MaxRestoreBytes, MaxRestoreExtractedBytes, MaxPackages | 500 MB, 2 GB, 150 | The most one restore downloads, unpacks and counts in NuGet packages, all together. |
MaxCacheBytes | 10 GB | Above it the folder takes no new restores, and the NuGet packages this server is not using are removed, least recently used first. |
MinFreeDiskBytes | 1 GB | Below this much free disk a restore does not start, and a download that would leave less is stopped. |
RestoreTimeoutSeconds, AddWaitSeconds, MaterializeWaitMilliseconds | 300, 120, 5000 | How long a restore may run, how long adding a NuGet package waits for its download, and how long a script waits for this server's download before it is asked to run again. |
AllowNativeAssets | true | Off, a NuGet package that carries native libraries is refused, and none is ever loaded. |
AuditRefuseLevel | Empty | low, moderate, high or critical: a NuGet package with a known vulnerability at or above it is refused. Empty refuses none, and vulnerabilities show as warnings; any other value fails every restore that adds a NuGet package. |
CertificateRevocationMode | online | online or offline: how the signing certificates of NuGet packages are checked for revocation. |
Frontend Templates
Reusable UI fragments written in TSX that the page editor can drop into a page. Useful when configured templates aren't enough, bespoke charts, unusual layouts, third-party widget integrations.
Common workflow: click Create, name the template, write a TSX
component in the editor. The component receives props: record (the current row
when embedded in a detail form), refresh (re-fetch data), and a few helpers.
Save. Reference the template from a Page Editor field.
Packageable: templates flow through the Package system like any other config object, they're cascaded in when a page that uses them is added to a package.
Gotchas: the TSX can't import arbitrary npm packages: it imports
shared code from @/ and the few packages the app carries.
PDF Templates
HTML + Scriban template + a small C# data script that renders to a PDF (headless Chromium). Each template is invokable by name from a script via REST or directly from a button on a Page (with a small adapter, see below).
Common workflow: click + Create. The editor opens with Name and Description text fields at the top, five tabs on the left side (HTML Template, Data Script, Settings, Params, JSON), and an always-on PDF Preview pane on the right. The Data Script and HTML Template tabs each open a Monaco editor on the left half; the preview re-renders when you save (green Update button top-right).
Declare parameters in the Params tab via the + Add button (Name, Type, Required, Default Value). The tab label updates to Params (n) with the count. Page format / orientation / margins / scale / header / footer live in the Settings tab.
Data script shape (see PDF Invoices tutorial for a real one):
var deal = await Db.GetAsync("deal", deal_id);
var lines = await Db.From("deal_line")
.Where("deal_id", "=", deal_id).ToListAsync();
return new {
InvoiceNumber = $"INV-{deal.id:D6}",
Lines = lines.Select(l => new { l.description, l.quantity })
}; HTML template shape (Scriban, similar to Liquid/Handlebars):
<h1>{{ InvoiceNumber }}</h1>
<table>
{{ for line in Lines }}
<tr><td>{{ line.description }}</td><td>{{ line.quantity }}</td></tr>
{{ end }}
</table> HTML encoding: every {{ }} value is
HTML-encoded, so a record value containing <b> prints as the text
<b>. html.raw is the explicit opt-out, for trusted HTML only:
{{ notes | html.raw }}. Never use it on values end users can
type, because that puts their markup into the rendered document. Only the template can opt
out, the data can't. html.escape and html.newline_to_br keep working
(newline_to_br escapes its text but keeps the <br /> tags).
Preview pane: always visible on the right side of the editor. Re-renders when you save (green Update button). Includes a thumbnail column for multi-page PDFs and a built-in viewer toolbar (zoom, rotate, download, print). The icon buttons in the editor's top-right toggle the preview pane's visibility and full-screen editing.
Calling it from outside the editor:
POST /api/v1/pdf-templates/{id}/preview- renders by ID, returnsapplication/pdf. Used by the editor's preview pane.POST /api/v1/pdf-templates/generate/{name}- renders by name, returnsapplication/pdf.POST /api/v1/pdf-templates/generate/{name}/base64- same but returns{ "data": "<base64>" }.
Pass parameter values in the JSON body. Auth is the standard Bearer token.
Trial limitation: both the generate endpoint and the
{id}/preview endpoint (used by the editor's preview pane) return 403
in trial mode, PDF rendering is gated entirely. You can still author the template
(data script, HTML, settings, parameters) and save it; you just won't see a rendered PDF
until you're on a paid environment.
Note on script-callable rendering: the C# scripting context exposes a
Pdf global, so a Script Module renders a stored template directly with
await Pdf.GenerateAsync("invoice", ...) or Pdf.GenerateBase64Async(...).
No HttpClient round-trip to the generate endpoint is needed. (The script
editor's autocomplete still advertises Pdf.RenderAsync and a Templates
object; neither name exists at runtime.)
Gotchas: Scriban escapes HTML by default. Page breaks are CSS-driven,
page-break-before: always on a section. Fonts available on the rendering
server are limited to system fonts plus the Noto and Liberation families, embed your own
via base64 @font-face if you need a brand font.
Images, fonts and stylesheets: the renderer only loads resources from public
http(s) addresses, plus data: and blob: URLs. Internal
and private-network addresses (localhost, 10.x,
192.168.x, container service names and so on) are blocked, so an image served by
an internal service will not appear in the PDF. Use a public URL or a data: URL.
Scheduled Events
The same Event Trigger system as Business Events, with the On Schedule trigger ticked. Use it for nightly recalculations, weekly digests, periodic cleanups, monthly reports.
Common workflow: create a Business Event with the On Schedule
trigger (instead of, or in addition to, the data-change triggers). Configure the cron
expression. Add an Execute Script action whose body invokes a Script Module via
Modules.CallAsync(...).
Cron expressions: Quartz-style 6 or 7 field, see Concepts → Scheduled Events for common patterns. Times are server time (UTC on the production stack).
Gotchas: a long-running job that overruns its cron interval doesn't double up, Quartz won't fire a second instance of the same job concurrently, the second tick is skipped. Failed runs don't auto-retry; build retry into the script.
Packages
Exportable bundles of configuration objects (BEs, pages, events, scripts, templates, schema) with cascading dependencies. Adding a Page to a package automatically pulls in everything that page references.
Common workflow (export): click Create, name and version the package. Add the top-level objects you care about, usually a handful of pages. Open the linked-items panel to see the cascaded list (what the package will actually contain). Untick anything you want to omit. Decide whether to include data (toggle on export). Click Export, you get a ZIP.
Common workflow (import): on the destination environment, open Packages, click Import, upload the ZIP. The platform shows what will be added or updated. Schema deltas are not auto-applied, if the package references a column that doesn't exist on the destination, import fails.
Gotchas: identifiers are by name, not numeric ID. A BE called "customer" on source binds to a BE called "customer" on destination, rename either side and the link breaks.
In git: the VS Code extension pulls a package into a folder as one file per artifact, and pushes the folder back, so a package can be reviewed and versioned like any other code.
Third-Party Data Connections
Connections to external databases (Postgres, SQL Server, MySQL, others) that import tables on a schedule. Imported tables behave like native tables, they show up in Schema Designer and can be referenced by Business Entities, Pages, and Scripts.
Common workflow: create a connection (connection string, test, save). List available remote tables; pick which ones to import. For each imported table, set sync mode (Full / Delta) and sync trigger (cron / manual / fixed interval).
Mirrored, not federated: the imported data lives in your own
omnicore-db. Reads are fast (local Postgres). Trade-off: staleness between
syncs.
App-managed columns: mark some columns as "managed by Archestack", they won't be overwritten by future syncs. Useful when you want to annotate vendor data with your own status flags.
Gotchas: the first sync of a large table can take a while. Delta syncs
require a column the connector can use as a watermark, usually a modified_at
timestamp.
AI Assistant
A chat assistant built into the app that can both answer questions about your data and change your environment. Ask "how many open work orders per branch this month?" and it queries and answers. Ask "add a status column to service appointments and a page to list them" and, if you are an administrator, it builds it.
Bring your own model. Archestack ships no LLM and pays for no inference. An administrator adds a provider on the AI Configuration screen: a base URL, a model name and an API key. Anything speaking the OpenAI chat-completions API works (ChatGPT, DeepSeek, OpenRouter, Groq, Together, a self-hosted Ollama or vLLM), and Anthropic's own API is supported directly. You can configure several and pick one per conversation.
Everything it does goes through the MCP Server. The assistant has no private back door into the platform, it calls the same tools any external MCP client would, carrying your identity. That means the limits you set there apply to it too, and every action it takes lands in the same audit log. See MCP Server below.
Common workflow: open the assistant from the sidebar or the floating bubble. Ask in one or two sentences. It reads your current schema before proposing anything, tells you what it is about to change, and waits for you to confirm. Each tool call appears inline under the answer so you can see what it actually did.
Works well for: ad-hoc questions across your data, CRUD-shaped features, simple automations, extending existing entities.
Less well for: non-obvious business rules and complex multi-step workflows. Treat what it builds like a junior developer's PR.
Gotchas: it works on live data and there is no undo, so take a backup before anything risky. Your prompts and whatever the tools return are sent to the provider you configured, under that provider's terms; point it at a self-hosted model if that matters. Long conversations only replay their most recent turns, so a chat left open all day will not remember the start of it. And note the two switches: with the MCP Server stopped the assistant can still talk, but cannot read or change a thing.
MCP Server
Every environment can expose itself over the Model Context Protocol, so an AI client outside Archestack, Claude Desktop, Claude Code, ChatGPT deep research, an IDE, or your own script, can work with it directly. It is also what the built-in AI Assistant runs on.
Off by default, and the tools that change how the application is built stay off even after you start it. That is deliberate.
Three groups of tools. Read covers listing and describing entities, querying rows, reading the schema, pages, events, script modules, packages, translations and event logs. Write is create, update and delete on records, and goes through the same path the UI does, so your Business Events fire exactly as they would for a person clicking Save. Admin is the group that lets a model build the application itself: entities, schema, pages, events, script modules, translations, packages and the NuGet packages scripts use.
Four gates decide whether a call lands: is the server running, is the group allowed, is that specific tool enabled, and does the token carry the scope. A tool blocked by any of them is invisible to the client rather than refused after the fact.
Tokens. Issue one per client on the MCP Server screen, with the scopes it actually needs and an optional expiry. The secret is shown once, at creation, and only a hash is stored, so a lost token gets rotated rather than recovered. Give each integration its own token: it keeps the activity log readable, and revoking one does not disturb the others.
Activity log. Every invocation is recorded, including the ones that were rejected and why. This is where you check what an integration has actually been doing.
Gotchas: a token with the admin scope is treated as a full administrator by everything downstream, so hand those out sparingly. "Allow token in URL" exists for clients that cannot send an Authorization header, but a URL ends up in logs and screenshots far more easily than a header does, leave it off unless you need it. And on a production environment with real data, leave the admin group off.
Object Browser
Raw view of every native and imported table, add, edit, delete rows directly. Includes a CSV import for bulk loading.
When to reach for it:
- Bulk-loading reference data before pages exist (countries, currencies, status enums).
- Debugging, eyeballing what's actually in a table when a page is misbehaving.
- Quick admin edits to data that's not yet exposed via a Page.
- CSV import: pick a table, upload a file, map columns to fields, preview, commit.
Gotchas: Object Browser does fire Business Events (it goes through the same EntityService write path as the front end). The exception is direct SQL via Database Deployments, that bypasses everything.
Dependency Graph
A read-only map of how the configuration objects in your environment reference each other. Eight resource types appear as nodes, coloured by type: Business Entities, Pages, Event Triggers, Script Modules, PDF Templates, Frontend Templates, Scheduled Events, and Packages. An edge between two nodes means one resource depends on the other. Reach for it to answer "what would break if I delete this?" and "is anything here unused?".
What an edge means: a Page bound to a Business Entity, an Event Trigger
registered on a Business Entity, a Page action step that fires an Event Trigger, a script
that calls another Script Module via Modules.CallAsync("Name"), a script that
renders a PDF Template, a Scheduled Event that fires an Event Trigger, or a Package that
contains a resource. It is a design-time view, it reflects what your configuration
wires together, not live runtime traffic.
Two kinds of problem are made to stand out visually:
- Loose ends (orphans) - nodes that nothing references and that reference nothing. They are collected into a red-bordered "Loose ends" band at the bottom of the canvas. A Script Module no one calls; a Page bound to nothing.
- Dangling references - drawn as red dashed edges. The reference exists in configuration but its target cannot be found, for example a Page action step still pointing at an Event Trigger that was deleted, or a script that calls a Script Module by a name that no longer exists.
Common workflow: open Dependency Graph from the Tools section of the sidebar. The left panel has a name search, a Layout picker (Left to right, Top to bottom, By type, Force-directed), the Group by package, Show loose ends and Show edges toggles, and a per-type checklist. Click any node to enter focus mode: that node and its direct neighbours stay bright while the rest of the graph dims, so you can trace exactly what one resource touches. Click the canvas background to clear focus. Every node carries a small open-in-new-tab button that jumps straight to that resource's editor. The stats line at the top reports node, edge, orphan, and dangling-reference counts.
Group by package: wraps each package's members in a labelled dashed box, so you can see a package as a unit and spot the resources it pulled in by cascade.
Gotchas: the graph is built on demand from the current state, use the
refresh button after you change resources elsewhere. Script-derived edges are found by
scanning script bodies for the literal Modules.CallAsync("Name") call shape, a
module invoked through a computed name will not show an edge. A dangling reference is always
worth fixing; an orphan often is not, a brand-new Script Module counts as orphaned until
something calls it.
Event Logs
The audit trail for everything the platform ran, Business Events, Script Modules, Scheduled Events, third-party syncs.
Common workflow: open Event Logs, set Status to Failed, click into an entry. The detail panel shows the payload (record state at the time, parameters passed), the exception with stack trace, and timing.
Logs and Charts: the page has two tabs sharing one filter row. Logs is the table above. Charts is an operations dashboard over the same filter: volume over time stacked by status, a success-rate trend, a per-source breakdown, the top failing and slowest events, average and max duration over time, a trigger-type breakdown, and a day-of-week by hour-of-day heatmap, with headline totals on top. Both tabs share a From / To date range (with Last 1h / 24h / 7d presets); the Charts tab auto-picks a sensible time bucket for the range and can refresh on an interval for live monitoring.
Real-time notifications: the bell icon in the top bar of the admin app surfaces unread failures via SignalR.
Gotchas: the log can grow large. Old entries are kept forever by default; if disk is a concern, set up a Scheduled Event that prunes entries older than N days.
User Management
Create, invite, and manage users; assign realm roles. Wraps the Keycloak admin REST API.
Roles:
- admin - everything; can manage all Business Units, users, roles, system settings.
- owner - manages their own Business Units, can edit administration and runtime pages.
- editor - can view and edit runtime pages, but not change schema/BE/event configuration.
- user - read-only on runtime pages.
Gotchas: demoting a user from admin mid-session doesn't kick them out, the change applies on next token refresh (typically within a minute).
Business Units
Keycloak-backed groups that gate visibility on individual resources. See Concepts → Business Units for the model.
Common workflow: create a BU. Invite members by email and pick the role. Assign resources by going to each BE / Page / Trigger or via the BU's linked-items panel.
Cascading assignments: when you assign a Page to a BU, the assignment cascades to the BE the page binds to.
Gotchas: a user's active BU is set in the top-bar BU switcher and
persists in localStorage. New users default to their first BU; if a list page
is suspiciously empty, check which BU is active.
Database Backups
Nightly pg_dump of all three databases, plus a ZIP of the workspace's
files. Manual trigger / upload / restore from the UI.
Common workflow: Trigger now → Download → Upload → Restore. Schedule defaults to nightly at 03:00 UTC.
Trial limitation: Backups are disabled in the trial environment.
Branding
Change the app's display name, logo (light + dark), colours, fonts and the rest of the Material UI theme, applied at runtime, no rebuild.
Common workflow: set app name, upload a light logo and a dark logo (PNG/JPG/SVG, ≤ 10 MB), pick the brand colours on the Theme tab. Save: your own session shows it at once, everyone else's on their next page load.
Theme: the Theme tab is a complete Material UI theme editor with a live preview
in light and dark mode: the brand colours and every palette colour per mode, the fonts (any
Google Font) and each text style, corner radius, spacing, shadows, animation speed, the style of
buttons, cards and inputs, component defaults, and component overrides as JSON. It checks
contrast, starts from presets, imports a theme from another Material UI app and exports this one
as code. Show on the whole app tries a draft on every screen before you save. A
workspace that never changes its theme keeps the built-in look exactly. In a repository the
theme is the theme key of workspace/branding.json.
Gotchas: the logo is also the browser tab's icon and the icon a phone shows for the app on its home screen. The home screen needs a PNG, JPG or WebP logo; with an SVG it shows Archestack's icon.
Analytics
Google Analytics on the published app, set up on the Analytics page (Configuration). The builder is never tracked.
Common workflow: paste the GA4 measurement ID (G-XXXXXXXXXX, under Admin, Data streams in Google Analytics), tick Track visits to the published app and save. Ask visitors for consent first is on by default: Google's tag loads only after a visitor accepts the banner, as the EU's cookie rules require. The banner's text, its buttons and a link to your privacy policy can be your own.
Events from page code: once the tag has loaded, page code can send its own events, such as window.gtag?.("event", "sign_up"). Nothing is sent while tracking is off or the visitor declined.
Translations
Key/value editor for the app's UI strings. Add new locales, override default labels per customer.
Common workflow: pick a locale, search for a key, type a new value, save. Refresh the admin app.
Gotchas: translations are loaded once per session; users on a stale tab don't see changes until they refresh. Some labels (column headers from BEs) come from the BE config, not from translations.
Script-callable APIs
The exact surface scripts can call. Verified against the source, every method below actually exists.
Globals
Every script gets these top-level variables (no other names exist in script scope):
Entity-dynamicExpandoObject. Current record. Access fields withEntity.column_name. MutatingEntityin a Before* trigger persists the changes as part of the in-flight write.OldEntity-dynamicExpandoObject. Previous record (update + delete triggers). Read-only.Log-ILogger.Log.LogInformation("…"),Log.LogWarning(…),Log.LogError(…). Writes to server logs and the Event Log entry.Db-DbHelper. Database access: a typed set per business entity, and the untyped helpers (see below).Modules- call other Script Modules. Each module is a method with its own parameters:await Modules.OrdersOf(customer: "Acme"), checked when the script compiles; the result isdynamic.await Modules.CallAsync("OrdersOf", new Dictionary<string, object?> { ["customer"] = "Acme" })still calls one by name and returnsTask<object?>.Pdf-ScriptPdfHelper. Render a stored PDF template by name:await Pdf.GenerateAsync("invoice", new Dictionary<string, string> { ["id"] = Entity.id.ToString() })returnsbyte[];await Pdf.GenerateBase64Async(…)returns the same payload base64-encoded. Parameters are forwarded to the template's data script as string-typed locals.Ocr-ScriptOcrHelper. Read the printed text out of an image, in-process, no external service and no API key:await Ocr.ExtractAsync(byte[] image)returns anOcrExtraction.await Ocr.ExtractBase64Async(string)takes the same image base64-encoded (adata:image/png;base64,prefix is tolerated),await Ocr.ExtractTextAsync(byte[])returns just the text.Doc-ScriptDocHelper. Find the document in a photo and flatten it:await Doc.CropAsync(byte[] image)returns aDocumentCrop.await Doc.CropBase64Async(string)takes a base64 capture.Files-ScriptFilesHelper. The workspace's own folder of files, the one the Files page manages:await Files.WriteAsync("invoices/1042.pdf", bytes),await Files.ReadAsync(path),ListAsync(folder),MoveAsync,DeleteAsync. See Files.
There is no User, Http, Email,
Templates, or any other global.
Db - single-row + write methods
All async, all on the top-level Db object:
await Db.GetAsync(string table, object id)- get a row by primary key. Returnsdynamic?(ornullif not found).await Db.CreateAsync(string table, object values)- insert.valuescan be an anonymous object or aDictionary<string, object?>. Fires Before/After Create triggers. Returnsint(the new ID).await Db.UpdateAsync(string table, object id, object values)- update by PK. Fires Before/After Update triggers.await Db.DeleteAsync(string table, object id)- delete by PK. Fires Before Delete trigger.
Db - fluent query builder
Start with Db.From(table). Chain filters, then a terminal:
.Where(string column, string op, object? value = null)- add a WHERE clause. Operators:=,!=/<>,>,>=,<,<=,LIKE,ILIKE,IN,IS NULL,IS NOT NULL. Chain multiple.Where(...)calls for AND..OrderBy(string column, bool desc = false)- set ORDER BY. Only one allowed per query..Limit(int limit)- max rows. Default is 1000.
Terminals (each runs the query):
await ...ToListAsync()- returnsList<dynamic>.await ...FirstAsync()- returnsdynamic?(first match or null). Temporarily sets limit to 1.await ...CountAsync()- returnsint.
There is no SumAsync, MaxAsync,
FirstOrDefaultAsync, or SingleAsync on Db.From. For
aggregations, fetch with ToListAsync() and reduce in C#, or use the typed sets
below, which have all of them.
Worked example
// Get a row by ID, query a list, calculate a sum, update.
var order = await Db.GetAsync("orders", 42);
if (order == null) return false;
var items = await Db.From("order_items")
.Where("order_id", "=", order.id)
.OrderBy("id")
.ToListAsync();
decimal totalAmount = 0;
foreach (var item in items) {
totalAmount += (decimal)(item.amount ?? 0);
}
await Db.UpdateAsync("orders", 42, new {
total = totalAmount,
updated_at = DateTime.UtcNow
});
return true; Db - business entities as typed sets
Every business entity is also a typed set on Db, named after the entity with the
spaces taken out: an entity called Service Order is Db.ServiceOrder. It is
EF Core underneath, so all of LINQ works (Where, Include,
SumAsync, GroupBy, projections), with a navigation for every foreign key
and every business entity join. Columns are PascalCase properties with their real types:
planned_date is PlannedDate, a DateOnly.
// Every business entity is a typed set on Db, named after the entity.
var since = new DateOnly(2026, 1, 1);
var open = await Db.Order
.Where(o => o.Status == "Open" && o.OrderDate >= since)
.Include(o => o.OrderItems)
.OrderBy(o => o.OrderDate)
.ToListAsync();
foreach (var order in open)
order.Total = order.OrderItems.Sum(i => i.Amount ?? 0);
var customer = await Db.Customer.SingleAsync(c => c.Email == "ada@example.com");
Db.Order.Add(new Order { Customer = customer, Status = "Open" });
// Writes every changed, added and removed row; business events fire as usual.
await Db.SaveChangesAsync();
return open.Count; - Names. A table no business entity is built on gets a set named after the table (
bank_accountisDb.BankAccount). A foreign key column without its_idsuffix is the navigation:customer_idisCustomer, and the other side is the plural,Customer.Orders. - Saving.
await Db.SaveChangesAsync()writes every added, changed or removed row the wayCreateAsync,UpdateAsyncandDeleteAsyncdo, so business events fire and audit columns are stamped, and a changed row sends only the columns that changed. It is not one transaction: rows saved before a failure stay saved.ExecuteUpdate,ExecuteDeleteand raw SQL writes are refused. - Types. A
timestamp with time zonereads as a UTCDateTime, and a value compared with one must be UTC too:DateTime.UtcNow. Columns of a type the model does not map (arrays, enums, geometric types) are left out; read them withDb.From. - Always current. The model is rebuilt whenever a table or a business entity changes, so a removed column is a compile error on its line rather than a failure at run time. A pending deployment lists the typed scripts it would stop compiling before you run it.
Db.From and the methods above keep working beside the typed sets, and the script
editor and VS Code complete the sets, the columns and the navigations.
Simulation mode
During trigger simulation (the Run Simulation button on a Business Event) and a Script
Module's Test tab, Db.SimulationMode is true. Writes
(CreateAsync, UpdateAsync, DeleteAsync and
SaveChangesAsync) check the table, the row and the values, record what they would
have done and write nothing, so no business event fires either. Reads see the real data. Writes,
moves and deletes through Files are simulated too, and mail and
notifications are recorded, not sent. This is what makes Run Simulation safe to use
against real data. Anything else the script does itself is real: HTTP calls,
System.IO, and anything a NuGet package does, such as a
connection to another database.
Modules
await Modules.OrdersOf(customer: "Acme")- every Script Module is a method named after it without the spaces ("Orders of" isModules.OrdersOf), taking the parameters it declares with their types, required ones first; an optional one left out gets its default. A misspelt name or a value of the wrong type is a compile error on its line. ReturnsTask<dynamic?>, so the result reads without a cast, an anonymous type included. Arguments read fromEntityare dynamic: cast them ((int)Entity.company_id) so the call is checked when the script compiles.await Modules.CallAsync(string moduleName, Dictionary<string, object?>? parameters = null)- call another Script Module by name. Parameters become typed variables in the called module's script. ReturnsTask<object?>(whatever the module returned).
Files - the workspace's folder
Every workspace has a folder of its own on the server, managed on the Files page
(Operations). Uploads land there, and so does anything a script keeps: a generated invoice, an
export, a trained model. Nothing in it is stored in a database, and no path reaches another
workspace's folder. Paths are relative and use forward slashes; a path with .., a
drive or a link in it is refused.
// A generated invoice, kept in the workspace's own folder.
var pdf = await Pdf.GenerateAsync("Invoice", new Dictionary<string, string> { ["id"] = Entity.orders_id.ToString() });
var path = $"invoices/{DateTime.UtcNow:yyyy}/{Entity.orders_number}.pdf";
await Files.WriteAsync(path, pdf);
// Read it back, or anything an admin uploaded on the Files page.
var rates = await Files.ReadTextAsync("config/vat-rates.json");
foreach (var file in await Files.ListAsync("invoices", recursive: true))
Log.LogInformation("{Path} is {Size} bytes", file.Path, file.Size);
return path; ReadAsync(path)returns the bytes and throws when there is no such file;ReadTextAsyncreads UTF-8 text;ReadDataUriAsyncreturnsdata:image/png;base64,…for an image in a PDF template or a mail.WriteAsync(path, bytes)andWriteTextAsynccreate the folders and replace a file already there. A write lands whole or not at all.ListAsync(folder, recursive)lists folders first; each entry hasPath,Name,IsFolder,Size,ModifiedUtcandContentType.MoveAsync(from, to)never overwrites;DeleteAsync(path)removes a file, or a folder with everything in it.PathOf(path)is the absolute path on disk, for a library that wants one.
In a dry run (the Test button) and in a test run, writes, moves and deletes never reach the
folder: the run keeps them to itself, so reading back a file it just wrote works, and lists them
with what it recorded. The .archestack folder holds the platform's own files (logos,
package icons, mail attachments): scripts may read it, never write it. A backup includes the
folder as a workspace-files archive beside the database dumps.
Ocr and Doc - reading a photo
Both run inside the API process. There is no OCR service to deploy, no key to configure, and no per-page cost.
OcrExtraction (what Ocr returns):
Text- every recognised line joined with newlines, in reading order.Lines- per-lineText,Confidence, and a bounding box:X,Y,Width,Height, plusRightandBottom. Pixels in the source image, origin top-left.Confidence- mean of the line confidences, 0 to 1.ElapsedMs,IsEmpty.
Use the boxes. Reading a document means locating a label and then reading what sits beside it,
which text alone cannot express: find the line containing "Total", then take the line whose
Y range overlaps it and whose X is past its Right.
DocumentCrop (what Doc returns):
Found- whether an outline was actually detected. Check this first.Image- PNG bytes of the flattened page, or the original bytes unchanged whenFoundis false. A photo that was never cropped still reads; one cropped to the wrong quadrilateral does not, so the detector declines rather than guesses.Corners- the outline in the source photo, top-left, top-right, bottom-right, bottom-left.Width,Height,ElapsedMs.
The two compose in the obvious order, and because a failed crop hands back the original,
chaining them always does something: var crop = await Doc.CropAsync(photo); var scan =
await Ocr.ExtractAsync(crop.Image);
Three limits worth designing around. The bundled model is PP-OCRv5 latin, so
western alphabets and digits, not CJK. It is not a PDF reader, a PDF page has to be rasterised
to an image first. And this is OCR, not document understanding: turning lines into vendor,
invoice number and total is logic you write, and it will be wrong sometimes, so check
line.Confidence and route low reads to a human instead of writing them straight to
a table.
Mind the script timeout. A full page takes a second or two and the first call in the process
adds about a second loading the models. The ten second timeout does not interrupt an
await, so a script that reads several pages returns nothing at all rather than
failing early. Do multi-page work in a Scheduled Event, one page per run.
Template expressions (Business Event actions)
Action configuration uses {{ … }} braces. The full list of valid tokens:
{{ Entity.column_name }}/{{ Entity.id }}{{ OldEntity.column_name }}{{ now() }}/{{ getdate() }}- ISO 8601 UTC datetime{{ today() }}- date string{{ guid() }}/{{ newid() }}- fresh GUID{{ year() }},{{ month() }},{{ day() }},{{ timestamp() }}- Unix seconds{{ empty() }},{{ null() }}- Aggregates within a join context:
{{ SUM(column) }},{{ AVG() }},{{ COUNT() }},{{ MIN() }},{{ MAX() }} - Arithmetic:
{{ Entity.quantity * Entity.price }}- evaluated post-substitution viaDataTable.Compute()
There is no {{ user.email }} or other user-scoped token.