core/* Module Reference
Every Tengo module the engine exposes, with signatures and gotchas.
Loading order
The Tengo adapter (internal/coreapi/tengo_adapter.go) registers built-in modules at engine boot. They are always available with import("core/<name>"); capability checks fire at call time, not import time, so importing a module you don't have permissions for is harmless. Module objects are immutable from script perspective — you can't monkey-patch core/log or replace core/nodes.create.
Every module is also exposed through the gRPC SquillaHost service to compiled Go plugins (under different naming — QueryNodes instead of core/nodes.query, SendEmail instead of core/email.send). The capability set is the same; only the surface differs.
core/log
Structured leveled logging that lands in the kernel's structured log stream (stdout in dev, the configured log sink in production). Caller info (theme/extension slug, script path) is auto-prefixed onto every line.
log.info(message, fields?)— informational; the most common level.log.warn(message, fields?)— something is off but not broken.log.debug(message, fields?)— hidden behindSQUILLA_LOG_LEVEL=debug; use freely without rate-limiting.log.err(message, fields?)— alias becauseerroris a reserved Tengo identifier.
fields is a Tengo map of structured key-value pairs. Avoid embedding values into the message string — the structured fields make grepping and aggregation tractable.
core/nodes
nodes.get(id)→ Node object, or error.nodes.query({node_type?, status?, language_code?, slug?, search?, limit?, offset?, order_by?, parent_id?, taxonomies?})→{nodes: [...], total: N}. Default limit 25, max 200.order_bytakes the same vocabulary as the SQLORDER BYclause (created_at DESC,title ASC); the kernel validates against an allow-list to prevent injection.nodes.create({title, node_type, status, slug?, layout_slug?, language_code?, fields_data?, blocks_data?, seo_settings?, featured_image?, taxonomies?, excerpt?, parent_id?})→ Node. Auto-generates the slug from the title if omitted. Firesnode.createdevent asynchronously after commit.nodes.update(id, partial_input)→ Node. Same shape as create; pass only the fields you want to change. Firesnode.updated.nodes.delete(id)→ undefined. Soft-delete: the row is hidden but preserved (revisions remain). Hard-delete is admin-only and not exposed to scripts.
core/nodetypes
nodetypes.register({slug, label, label_plural, icon?, description?, taxonomies?, field_schema?, url_prefixes?})→ NodeType. Idempotent on slug; calling twice with the same slug updates the existing definition.nodetypes.get(slug)→ NodeType.nodetypes.list()→ [NodeType].
nodetypes.register field_schema entries use "name":. block.json field_schema entries use "key":. Mixing them produces empty admin inputs even when fields_data is correct. The asymmetry is historical; the loaders refuse to translate one to the other on your behalf because the silent translation would mask far worse bugs.core/taxonomies
taxonomies.register({slug, label, label_plural, hierarchical?, show_ui?, node_types?, field_schema?})— register a taxonomy attached to one or more node types.hierarchical: truemeans terms can haveparent_id(categories);falseis flat (tags).taxonomies.get(slug)/taxonomies.list()/taxonomies.update(slug, partial)/taxonomies.delete(slug)
core/terms
terms.list(node_type, taxonomy)→ [Term].terms.get(id)→ Term.terms.create({node_type, taxonomy, slug, name, description?, parent_id?, fields_data?})→ Term. Note the{slug, name}object form when storing terms on nodes — seecore.guide'sterm_field_shapegotcha for why bare slugs are rejected by the admin.terms.update(id, updates_map)→ Term.terms.delete(id)→ undefined. Cascades to remove the term from every node that referenced it.
core/menus
menus.upsert({slug, name, items: [{label, url|page, target?, children?}]})→ Menu. Idempotent on slug. Thepage: "<node-slug>"form resolves to the node's id at upsert time, so renaming the page later doesn't break the menu — the menu item points at the node by id, and the URL is regenerated from the node's current slug on every render.menus.get(slug)/menus.list()/menus.delete(slug)
core/settings
settings.get(key)→ string. Returns""if missing; check with== ""rather thanis_undefined.settings.set(key, value)— emitssetting.updatedevent with{key, language_code, value}after commit. Sensitive-shaped keys (matched by suffix patterns like:secret,:token,:password) are auto-encrypted at rest using the AES-256 master key.settings.all(prefix?)→{key: value, ...}. Useful for fetching a whole namespace (settings.all("squilla.brand.")).
core/theme_settings
theme_settings.active_theme()→ slug, or""if no theme is active.theme_settings.pages()→[{slug, name, file, icon}]for the active theme.theme_settings.get(page_slug, field_key)— requirestheme_settings:readcapability. Theme scripts get this automatically; extensions must declare it.theme_settings.all(page_slug)→ field map keyed by field key.
core/events
events.emit(action, payload?)— fire-and-forget, non-blocking. Goroutine-dispatched; the call returns immediately.events.on(action, script_path, priority=50)— subscribe.events.subscribe(action, script_path, priority=10)— same ason, just a lower default priority. Use whichever reads more naturally; the priority can be overridden anyway.
core/filters
filters.add(name, script_path, priority=10)— register a filter handler. Lower priorities run first.
core/routes
routes.register(method, path, script_path)— mount an HTTP handler. Paths containing a dot mount at the top level (/sitemap.xml); other paths mount under/api/theme/for theme scripts. Extensions should prefer the manifest'spublic_routesfor stable URLs.
core/wellknown
wellknown.register(path, script_path)— handler under/.well-known/<path>.pathis the suffix ("security.txt","acme-challenge/*"). Trailing*is a prefix wildcard; everything after the wildcard lands inrequest.params.rest.
core/http
http.get(url, options?)/http.post(...)/http.put(...)/http.delete(...)— method-specific shortcuts.http.fetch(method, url, options?)— generic.
Options: {headers: {...}, body: "...", timeout: 30} (timeout in seconds, capped by the 10-second script wall-clock so anything larger is a soft hint, not a hard ceiling). Returns {status_code, body, headers, error?}. Outbound requests honor a configurable allow-list and block private network ranges by default to prevent SSRF.
core/email
email.send({to, subject, body, html?, from?, reply_to?, cc?, bcc?, headers?, template_id?, template_data?})— enqueue an email through the dispatcher. Routes through theemail.sendevent; an extension declaringemail.providermust be active or the call fails with "no provider". When a template id is set, the email-manager extension renders the template againsttemplate_databefore handing off to the provider.
core/media
Available only to extensions that declare the relevant media:* capability — themes do not get media access by default.
media.upload({filename, mime_type, body_base64, alt?, caption?, tags?})→ MediaFile ({id, slug, url, ...}). Routes through whichever active extension declaresmedia-provider.media.get(id)/media.query(query)/media.delete(id)media.import_url(url, opts?)— fetch a remote URL and import as a media file. Honors the SSRF allow-list.
core/data
Raw row CRUD against extension-owned tables. Subject to the data_owned_tables ownership check on top of the capability check; kernel-private tables (users, sessions, content_nodes, etc.) are denied unconditionally. Themes do not get this module.
data.get(table, id)→ row map.data.query(table, {where?, order_by?, limit?, offset?})→{rows, total}.data.create(table, data_map)→ row map.data.update(table, id, data_map)→ undefined.data.delete(table, id)→ undefined.
core/files
files.store(path, data_bytes_or_string)→ string (final path).files.delete(path)→ undefined.
Paths are relative to the configured storage root; absolute paths and traversal escapes are rejected. Used for assets that don't belong in the media library (cached renders, generated PDFs, etc.).
core/assets
assets.read(rel_path)→ string. Path is resolved against the theme/extension root (where the script lives); absolute paths and traversal escapes are rejected at resolution time.assets.exists(rel_path)→ bool.
core/users
users.get(id)→ User.users.query({role?, search?, limit?, offset?})→{users, total}.
Read-only. Scripts cannot create, update, or delete users — those operations live behind admin-authed handlers in the kernel.
core/helpers
helpers.json_encode(value)→ string. Returns"null"on encode error.helpers.json_decode(string)→ any. Returns undefined on parse failure; check withis_undefined.
core/routing
Available only inside render-scope scripts (filter or event handlers running during a public-page render). Outside the render scope these calls return zero values.
routing.is_404()→ bool.routing.is_homepage()→ bool.routing.site_setting(key)→ string. Convenience for reading a site setting from inside a render-context handler without re-importingcore/settings.
Standard library
The Tengo standard library is enabled: fmt, json, math, times, rand, enum, text, plus a stripped-down os with only os.read_file_lines redirected through the kernel's asset resolver. See the upstream Tengo docs at github.com/d5/tengo for full signatures and quirks.