Site apps
An app adds to a site without the theme knowing it — the way a Shopify theme app extension adds an app block or an app embed to a store. It is a package of Liquid blocks a theme section may accept, embeds the platform places in every page's head or foot, assets and locales. An app's Liquid runs under exactly the theme renderer's sandbox: the same tags, the same filters, the same HTML-encoded output, the same step and recursion budget, and nothing more. Its JavaScript goes through the same analyzer as a theme's and is served under the same policy: only on the site's own host, and only from a version the platform ships or the marketplace review released.
The package
An app is a folder — a ZIP when it travels — with abizvo-app.json at its top and six possible folders: blocks, embeds, assets, locales, screenshots, documentation. The theme package's path rules apply (forward slashes, no .., four levels, the extensions the platform serves), and the ceilings are smaller than a theme's: at most 300 files, 15 MB in all, 5 MB per file, 256 KB per text file, 40 blocks and 10 embeds.
abizvo-app.json
blocks/calendar.liquid
embeds/badge.liquid
assets/calendar.css
locales/en.default.json
locales/fr.json
documentation/README.md
The manifest
{
"schemaVersion": 1,
"appApi": "1.0",
"key": "scheduling",
"name": "Scheduling",
"version": "1.0.0",
"author": "Abizvo",
"description": "A booking calendar block and a badge.",
"permissions": ["booking", "services"],
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "Book a time" }
],
"blocks": [{ "name": "calendar" }],
"embeds": [{ "name": "badge", "target": "body" }]
}
keyis 2–40 lowercase letters, digits and hyphens starting with a letter, unique across every app; a block's type in a theme's data is@app/{key}/{block}. The key belongs to whoever released it first — a marketplace product, a workspace — and nobody else can add a version to it.versionis semantic (1.0.0); every release needs a new one, and a released version never changes.appApiis1.0.settingsare the app-level settings the owner fills in once per site, read in Liquid asapp.settings.{id}. They use the theme setting types — every one butcolor_scheme_group, which belongs to the theme.permissionsnames the site globals the app may read:services,booking,products,albums,collections,forms(the business's published forms with their fields),reviews(the business's published customer reviews),payments(whether the business can take card payments, and where a pay form posts),faq(the business's published FAQ),portal(where a Get my account link form posts),team(the people the business shows on its website),newsletter(the business's email signup forms and whether its plan includes email marketing),events(the business's published, upcoming events and where a registration posts),locations(the locations the business shows on its website),files(the file requests the business offers on its website),announcements(the announcement showing on the page right now),loyalty(the loyalty programme's public rules while it is on). A global reads as nothing in its Liquid (albumsalso coversgallery;collectionsalso coversentryandcollection). The site'ssettings,page,site,business,linklists,request,templateandroutesare always readable.blocksandembedsare optional declarations; the files are the truth, and a declared name with no file is a warning.
Blocks
A block is blocks/{name}.liquid with a {% schema %} exactly like a theme block's — a name, settings, presets — and optional {% stylesheet %} and {% javascript %} tags. It reads app (key, name, version, settings), block (id, type, settings, abizvo_attributes) and the parent section, plus the permitted globals.
<div class="cal" {{ block.abizvo_attributes }}>
<h3>{{ app.settings.heading }}</h3>
{% if booking.enabled %}
{% for service in booking.services %}<a href="{{ service.url }}">{{ service.name }}</a>{% endfor %}
{% endif %}
<img src="{{ 'calendar.svg' | app_asset_url }}" alt="">
</div>
{% schema %}
{ "name": "Booking calendar", "settings": [{ "type": "text", "id": "note", "label": "Note" }], "presets": [{ "name": "Booking calendar" }] }
{% endschema %}
A theme section takes app blocks by declaring { "type": "@app" } in its blocks — the same declaration Shopify themes use — and rendering them with {% content_for 'blocks' %}. In the editor the block picker then lists every installed app's blocks under the app's name; a block's own settings are edited in the inspector like any theme block's. A block whose app is not installed on the site renders nothing on the page and is dropped, with a warning, at the next save or publish; a section that does not declare @app refuses it.
Write {{ block.abizvo_attributes }} on the block's outer element, as a theme block does, so the editor can select it. The platform wraps every app block in <div class="ab-app-block ab-app-block--{key}-{block}" data-ab-app-block="{key}/{block}">.
Embeds
An embed is embeds/{name}.liquid with a schema naming its target — head (inside <head>), body-start (right after <body>, before the theme's header group — an announcement bar) or body (before </body>, the default). The platform places every enabled app's embeds on every page without the theme opting in, beside the Powered-by line. A theme that wants to choose where the body embeds go writes {% content_for 'app-embeds' %} or {{ app_embeds }} in its layout, and the platform then leaves them there; a body-start embed is always placed by the platform, first thing inside <body>, whatever the layout does. An embed reads app and embed (name, target, settings) and the permitted globals.
The token contract
An app is drawn on a page it did not design, so it draws with the theme's own vocabulary and nothing else:
- Colours are the theme's custom properties —
--color-background,--color-text,--color-muted,--color-border,--color-accent,--color-button,--color-button-textand the rest of--color-*— and the.color-{scheme}classes when a block wants a whole scheme (<div class="color-{{ block.settings.color_scheme }}">). Every theme writes these from its colour schemes, so an app that reads them takes the theme's colours for free. - Type is
--font-bodyand--font-heading(and the other--font-*properties a theme declares); shape is--radius-*(--button-radius,--card-radius,--input-radius); rhythm is--space-*. - No hard-coded colour. A colour literal —
#hex,rgb(),rgba(),hsl(),hsla()— in a declaration value is a warning from the app check (style.hard-coded-colour) on every{% stylesheet %}and everyassets/*.css. The one place a literal belongs is the fallback of avar():color: var(--color-text, #141414)keeps the contract,color: #141414breaks it. A shadow or a tint is mixed from the theme's colours —box-shadow: 0 10px 30px color-mix(in srgb, var(--color-text) 18%, transparent)— rather than painted black. The check reads the stylesheet as tokens, so a#bookingid selector, a hash in a comment or a string is not a colour. - A colour the owner picks is a value, not a stylesheet. An app may offer a
colorsetting (the Assistant's own accent) and write it into an inlinestyleas a custom property; the check never reads Liquid, only CSS, so a setting's default is where a brand colour may live.
Both official apps pass with no style.* warning; a marketplace app with one is reviewed with it on the report.
Assets, styles and scripts
{{ 'calendar.svg' | app_asset_url }} is a file under the app's own assets/, by app version and content hash, kept a year; asset_url reads the theme's assets and is flagged in an app. Every block's and embed's {% stylesheet %} is bundled into one file per app, and every {% javascript %} into another, each linked in the head only on a page that shows something of the app. The script bundle is sent only where the theme's own would be: on the site's own host, from a version the platform ships or the marketplace review released. A developer's own app's script never runs. An SVG asset is served with a policy that forbids script.
Locales
locales/en.default.json and locales/fr.json are read by {{ 'badge.text' | t }} inside the app's own Liquid first, then the theme's — so an app may reuse a theme's words and a theme can never change an app's.
The app check
The app check gates every import and every release, and is the marketplace's artifact review. It refuses a key, name, version or appApi the manifest gets wrong (manifest.key, manifest.name, manifest.version, manifest.app-api), a permission outside the list above (manifest.permission), a package with no block and no embed (structure.empty), more than the limits (structure.blocks, structure.embeds), Liquid that does not parse (liquid.parse), {% render %} — an app has no snippets — and {% sections %} (liquid.render, liquid.sections), a block schema with no name (block.name-missing), an embed target that is not head, body-start or body (embed.target), a preset naming a setting the block does not declare (preset.setting), a setting definition the platform refuses (setting.definition, setting.duplicate-id, setting.scheme-group-place), an app_asset_url naming a file the app does not ship (asset.missing), an image whose bytes are not what its name says (security.mime), and JavaScript over the budget or doing what no theme may do — the theme JavaScript analyzer's own refusals (js.eval and the rest). It warns about a declared block or embed with no file, a missing README (documentation.readme), a translation with no text in the default locale (locale.key-missing), asset_url in an app (asset.theme), a hard-coded colour outside a var() fallback (style.hard-coded-colour, the token contract above), an image over the budget, CSS over 100 KB, JavaScript over 100 KB (refused over 250 KB), and the accessibility slips the theme check warns about.
The workspace
/app/apps/develop in the portal (it used to be /app/website/apps, which now forwards there) is an organization's own apps: start a blank one (a manifest, one block, one embed, a locale and a README — enough to pass the check and show on a page), import a ZIP you wrote locally, check, release, carry on in a new version, export. A released workspace app installs on that organization's own theme sites and nowhere else, and its script never runs; selling it is a marketplace submission.
Themes that recommend apps
A theme says which apps belong with it in its manifest:
"apps": {
"recommended": ["assistant"],
"required": []
}
recommendedkeys are offered first — Recommended by {theme} — on the site's Apps page and in the editor's Discover list, in the theme's own order; one already on the site is listed as installed rather than dropped.SiteAppService.RecommendedForSiteAsync(siteId)is the one ordering both read.requiredkeys are installed on every site of the theme as it is created, when the theme is added to a site's library and when it is updated — beside the apps whose blocks the theme's own data places. The owner may still uninstall one afterwards.- Every key must be an app the platform knows: an official app, or one the host's app store holds a released version of. The theme check refuses an unknown key (
theme.apps.unknown) — a key nobody has released would install nothing and recommend a blank.
Every official theme recommends the Assistant. The generator behind the thirteen template themes (tools/TemplateThemes) names the apps each will recommend once they ship — galleries for photography and portfolio, quotes for the trades, the catalogue for restaurant and retail.
Installing on a site
/app/apps lists every app the business holds — the sites each is on, with a switch per site, Add to a site and Uninstall (what the app collected is kept) — and the ones it could add, its themes' recommendations first; /app/apps/{key} shows one app with its usage and README. /app/website/{site}/apps lists what a site has installed and what it could install — the platform's own apps, the ones the organization bought, its own released apps — with the live theme's recommendations first. Installing an app fills its settings with their defaults; the settings are edited there, checked by type like a theme's. An app can be switched off (installed, rendering nothing) or uninstalled. Nothing reaches a visitor until the site is published: a publication freezes each enabled app's version and settings beside the theme, so an update or a settings change shows on the live page only through the next publish. Update to x.y.z moves the draft to the app's newest released version, keeping the settings it still declares and listing the ones it dropped.
A worked example: the Scheduling app
The first app, free, by Abizvo — and the one every official theme's contact page already carries. It reads only the booking and services globals (its manifest's permissions), ships three blocks and one embed, and runs no JavaScript at all.
abizvo-app.json key "scheduling", permissions ["booking", "services"]
blocks/booking-calendar.liquid every open time of the next fortnight, per package
blocks/booking.liquid each bookable service with its next times and a Book button
blocks/services.liquid the catalogue with prices; a Book button where a service is bookable
embeds/booking-badge.liquid a floating "Book now" button (body), only when the app setting turns it on
locales/en.default.json, fr.json
documentation/README.md
Its app settings — filled once per site on /app/website/{site}/apps — are the words (heading, introduction, the Book button, "when nothing is open"), how far a visitor may page (weeks_shown), whether prices show, and the Book now button with its label. A block reads them as app.settings.heading and so on; its own settings (block.settings.times_per_day, block.settings.limit…) are per placement, edited in the theme editor.
A theme hosts it with a section that accepts app blocks. Every official theme ships one, sections/app-blocks.liquid:
<div class="section color-{{ section.settings.color_scheme }} ab-scheme">
<div class="page-width">
{%- if section.settings.heading != blank -%}<h2>{{ section.settings.heading | editable: section, 'heading' }}</h2>{%- endif -%}
{% content_for 'blocks' %}
</div>
</div>
{% schema %}
{
"name": "App blocks",
"settings": [ { "type": "text", "id": "heading", "label": "Heading" }, { "type": "color_scheme", "id": "color_scheme", "label": "Colour scheme", "default": "scheme-1" } ],
"blocks": [ { "type": "@app" } ],
"presets": [
{ "name": "Booking calendar (Scheduling app)", "settings": { "heading": "Book a time" },
"blocks": [ { "type": "@app/scheduling/booking-calendar", "settings": { "show_heading": false } } ] }
]
}
{% endschema %}
and places the block in its contact template:
{
"sections": {
"form": { "type": "contact-form" },
"booking": {
"type": "app-blocks",
"settings": { "heading": "Book a time" },
"blocks": { "calendar": { "type": "@app/scheduling/booking-calendar", "settings": { "show_heading": false } } },
"block_order": ["calendar"]
}
},
"order": ["form", "booking"]
}
The block itself is the calendar the Starter theme's own booking-calendar section drew, with section.id become block.id and the theme's words become the app's:
{%- if booking.enabled -%}
<div id="booking" class="bkcal-block" {{ block.abizvo_attributes }}>
{%- for item in booking.services limit: block.settings.limit -%}
<input class="bkcal__radio ab-visually-hidden" type="radio" name="bkcal-{{ block.id }}" id="bkcal-{{ block.id }}-{{ item.handle }}" {% if forloop.first %}checked{% endif %}>
{%- endfor -%}
{%- for item in booking.services limit: block.settings.limit -%}
<section class="bkcal__panel" aria-label="{{ item.name }}">
{%- for day in item.days -%}
{%- for time in day.times limit: block.settings.times_per_day -%}
<a class="bkcal__time" href="{{ time.url }}">{{ time.time }}</a>
{%- endfor -%}
{%- endfor -%}
<a class="button" href="{{ item.url }}">{{ app.settings.book_label }}</a>
</section>
{%- endfor -%}
{%- if booking.later_url and booking.window < max_window -%}<a href="{{ booking.later_url }}">{{ 'calendar.later' | t }}</a>{%- endif -%}
</div>
{%- elsif request.design_mode -%}
<p>{{ 'calendar.off' | t }}</p>
{%- endif -%}
Two things the platform does around it. A theme that places an app's block gets the app installed for it: a new site of the theme, a theme added to a site's library and a theme update each install every app the theme's own data names (@app/{key}/… in its templates and groups) when it is missing, so the contact page renders as shipped rather than with a hole; the owner may uninstall it afterwards, and a save never puts it back. A page that carries the calendar pays for the day-by-day availability read, and no other page does — the same rule as the theme's own booking-calendar section, now also true of @app/scheduling/booking-calendar at any depth under a section that accepts app blocks, including through a saved section.
A theme keeps its own booking-calendar section where it had one, so a site already using it keeps working; new sites get the app's block. In a theme you write, the same host section is all it takes; a theme without one still lets an owner install the app, but has nowhere to put its blocks.
The official apps
Assistant is on every theme site from the moment it is created: a chat button in the corner of every page, drawn in the theme's own colours and type from its custom properties (--color-button, --color-background, --color-text, --font-body, --card-radius and the rest — a theme that names them gets a panel that matches it for free). Its greeting, quick replies, corner and colour are per site; the assistant's own answers are the business's, shared with text messaging. A theme wanting to place it writes {{ app_embeds }} in its layout like any embed. It posts to routes.chat_url, a global any app may read — nil on the editor canvas, the preview and a site whose chat is off.
Reviews shows the business's customer reviews on any page: blocks reviews (a grid, or a row a visitor swipes through by CSS scroll snapping), rating-summary (the average and the count, with the AggregateRating structured data only when at least one review is published) and leave-review (a note that reviews come by invitation, with an optional link — never a form). It reads only the reviews global, which carries published reviews and nothing a customer did not write for the public. Reviews are asked for under Operate → Reviews in the portal — a single-use, thirty-day link the customer answers at /r/{token}, or from their own customer portal — and a review is on the page only once the owner publishes it; publishing or hiding one needs no republish of the site.
Gallery puts the business's albums on any page: an Albums grid with a category filter that needs no script, an Album block (choose one with the album setting, or leave it empty on the album page to show whichever album was opened — photos, lightbox, downloads, the guest-code form for a private album), Before and after pairs with a slider, and the Favourites panel for proofing. It reads only the albums global; an album with a guest code never shows a photo, or its cover, outside its own page. The lightbox and the slider are the platform's, sent only to a page whose markup asks for them with lightbox_attributes or compare_attributes.
Payments lets a customer pay an invoice from the site: block pay-invoice is two fields, the invoice number and the email address it was sent to, posting to POST /sites/{slug}/pay. When both match one of the business's open invoices the customer goes to Stripe Checkout for the invoice's balance, on the business's own Stripe account; a wrong number, a wrong address and an already-paid invoice all come back with the same sentence, so the form never says whether an invoice exists. The block is on the page only while the business can take card payments (its plan includes online payments and Stripe has enabled charges); otherwise a visitor sees nothing and the owner a note on the canvas. There is no single-invoice pay button: an invoice's public link is a secret only its customer holds.
Newsletter puts one of the business's email signup forms (Marketing → Forms) on a page: block signup draws the form's own fields, its consent checkbox in the form's own words and its button, and posts to the same POST /f/{key}/submit the hosted form and the embed use, with _source=site:{slug}, the block in _block and the page in _return. A signup lands in the marketing contacts with ConsentSource.Form — Pending until the confirmation click where the form asks for double opt-in — and the visitor comes back to the page with the form's thank-you (?sent={block}), "check your inbox" (?sent={block}:confirm), a neutral thank-you for an address that cannot be subscribed (?sent={block}:noted) or the reason (?formError={block}&reason=). Embed signup-bar (body-start) is off until the owner turns it on; a visitor closes it with a native <details>, no script. The app's default_form setting (a signup_form) names the form the bar, an unset block and the theme's own {% form 'newsletter' %} sign people up to — else the first one made. The global, read live and only on a site with an app that asked for it:
newsletter.entitled the plan includes email marketing
newsletter.ready entitled and at least one signup form is switched on
newsletter.source what a block posts as _source: "site:{slug}"
newsletter.honeypot the platform's hidden field, as markup
newsletter.forms every live signup form, oldest first:
form.id, form.title, form.description, form.button_label, form.success_message
form.collect_first_name / collect_last_name / collect_phone / collect_company
form.require_consent, form.consent_text, form.double_opt_in
form.url the hosted page (/f/{key})
form.action where the form posts; nil on the editor canvas and the preview
form.inert true where action is nil
Without email marketing, or with no signup form, the block shows nothing to a visitor and a note with a link on the canvas. A theme's own newsletter section is routed to the default signup form on a plan with email marketing, so its signups are contacts with form consent too; otherwise they stay leads whose notes say no marketing consent was recorded.
FAQ shows the business's own questions and answers — one list per organization, written on /app/website/faq, shared by every site and outliving any theme. It asks for the faq global and nothing else, and runs no script. The global, read live (never frozen into a publication) and only on a site with an app that asked for it:
faq.enabled true when at least one question is published
faq.items_count how many
faq.items every published question, in the owner's order:
item.id, item.handle the id, and "faq-" + the random tail of it (an anchor that never collides)
item.question the words, as text
item.answer the answer as HTML the platform made from its rich-text markup
item.answer_text the answer as plain words
item.category the category's name, or nil
item.category_handle the name handleized ("" for none)
faq.categories_count how many named categories
faq.categories the same items grouped: group.name, group.handle, group.items_count, group.items
Its blocks are faq (an accordion of native <details>, optionally one category), faq-search (every question as a link to its anchored answer, with topic links) and faq-schema, which writes {{ faq | faq_schema_tag }}: the page's one FAQPage JSON-LD, built by the platform with the answers as plain words, written the first time a page asks and empty after — so a schema placed twice, or by two apps, still makes one — and nothing when no published question has an answer. A draft never reaches any of them.
Team shows the people behind the business. Who appears is decided on each person's page in the portal, under HR → Employees → On the website (people.manage): a switch that is off for everybody until the owner turns it on, a title for the website (80 characters), a plain-text introduction (600, a blank line a new paragraph), a photo from the site media library and an order. It asks for the team global and nothing else, and runs no script. The global carries a name, the website title, the introduction, the photo and the order — never an e-mail, a phone, pay, a role or an id — read live, never frozen, so a person switched off leaves the page within the read's half-minute cache:
team.enabled true when at least one person is shown
team.count how many
team.members the people, in the owner's order then by name:
member.name first and last name from their account (a person with no name is not shown)
member.title the title written for the website, or nil
member.bio the introduction as encoded paragraphs
member.bio_text the introduction as typed
member.initials for a person without a photo
member.photo nil, or src / src_sm / src_md / src_lg / srcset / width / height / alt — hand it to image_tag
member.order the number the owner set
Its blocks are team (a grid of two to four columns, square, portrait or round photos, with or without each introduction) and team-member, one person chosen with a team_member setting — the fortieth setting type, offering only the people shown and refusing another business's person at save and at publish. Somebody switched off after being chosen simply renders nothing; the setting keeps its value so the owner's next save is never refused for it.
Events shows the business's own events — a workshop, a class, an open house — written on /app/events (Operate → Events; events.view reads, events.manage writes), one list per organization shared by every site. It asks for the events global and nothing else, writes leads (capability leads) and runs no script. The global holds published events that have not ended, soonest first, with times told in the business's time zone and worded in the site's language, read live and never frozen — a seat taken shows within the read's half-minute cache, and an event that is over leaves every page on its own. Never who registered. Shape on the Liquid page.
Its blocks are events (every upcoming event as a list or cards: date badge, time, place or Online, seats left, and what a visitor can do — register in a native <details> form, Book into /book/{slug}/{service} for an event booked through one of the business's bookable services, Join online, Add to calendar), event (one event, chosen with a business_event setting — the forty-second setting type, offering the business's own events and refusing another business's at save and at publish; an event over, unpublished or removed renders nothing) and event-register (the registration form for one event, with its thank-you, full and closed states, or the Book button instead). A registration posts to POST /sites/{slug}/events/{id}/register (the forms limiter, the shared honeypot, no antiforgery, never cached): the platform takes the seats by a conditional update, so the last seat goes to one visitor; the visitor is filed under Leads (source site:event, an existing lead with the same address reused) and the owner is e-mailed. The calendar file is GET /sites/{slug}/events/{id}.ics. Each event carries one Event JSON-LD per page through {{ ev | event_schema_tag }}.
Catalogue puts the business's products and services on any page. It asks for the products and services globals and nothing else, writes nothing into the business and runs no script. Products are only the ones marked Show on website (Inventory → Bulk edit), services only the published ones, and both are read live: a price changed in the portal reaches a published page at the next read with no republish, because a publication freezes no catalogue. Prices arrive already worded ($45, $12.50 USD, $90 /hour); the app never works one out. Its blocks are products (a grid of two to four columns or a list, optionally one category, "From" wording, each product's category, description and stock word, a Book button only on a product the business made bookable — there is no public checkout, so every other product is display-only — and "Prices before tax." only when a product shown is taxable), product-card (one product, chosen with a product setting), price-list (the published services and their prices, display only — the Scheduling app's Services with booking is the one with Book buttons) and availability (whether one product is In stock, Only a few left or Sold out). A product's stock word comes from the inventory fold against the product's own low-stock threshold, and only for a product whose stock is counted — never the number on hand.
Announcements puts a short notice across the business's website — closed for the holiday, spring bookings open — written on /app/website/announcements (Website → Announcements; sites.view reads, sites.manage writes, the FAQ's pair), organization-level, for every site or one. Each has plain text (200 characters), an optional link (through the link policy), a start and an end typed on the business's own clock, a tone, a placement and whether a visitor may close it, and is off until switched on. It asks for the announcements global and nothing else, writes nothing into the business and runs no script. The global is chosen at each request — never frozen — so an announcement starts and ends on its own schedule with no publish, and a theme page's validator carries which one it shows, so a browser's copy is never "not modified" across a change.
Its embeds are bar (body-start: a region named Announcement above the theme's header, closed with the × of a native <details open>, back on the next page) and popup (body-start: a card in the corner, the same <details> close, never taking the focus or covering the page, sliding in only under prefers-reduced-motion: no-preference, and never drawn on the editor canvas). Colours are the theme's scheme tokens only — info a wash of the accent on the background, warning the text colour inverted, promo the button colours — and an Important: word for screen readers carries the warning tone. While the app shows an announcement, every theme's own announcement-bar section renders nothing (on the canvas, a note says why); the owner's text there is kept and returns when the announcement ends.
Quotes takes a request for a quote on any page: block request lists the business's published services (the services global) as tick boxes with an optional quantity, asks for a preferred date and time of day and for a way to reply, and posts as the platform's quote form; block request-button links to the hosted request page (business.quote_url). A request lands under Operate → Leads exactly as one from the hosted request page does — the ticked services, quantities and prices on it — so Draft quote there turns it into a draft quote with those services. Only the business's own published services are ever read back from a post.
Customer Portal lets a customer get back into their account from the site: block portal-link is one field, an email address, posting to POST /sites/{slug}/portal-link. When one of the business's active customers has that address, a fresh link to their existing account page goes to the address on file, from the business's name; the link before it stops working. Every visitor — a customer, a stranger, someone asking twice within ten minutes — sees the same sentence, so the form never says who is a customer. The embed portal-menu-link is an optional Your account link on every page (off until the owner turns it on and picks the page); an app cannot reach into a theme's own header, so it sits in the strip above it.
Locations shows the places the business receives people — kept once per organization on /app/settings/locations, shared by every site and outliving any theme. It asks for the locations global and nothing else, and runs no script. The global, read live (never frozen into a publication) and only on a site with an app that asked for it, carries only the locations the owner marked "Show on the website", primary first — and the street and the map pin only when the business publishes its address (Settings → Show this address on my website); the city and province always:
locations.enabled true when at least one location is shown
locations.count how many
locations.maps_count how many have a pin (and so a map)
locations.items each location:
location.id, location.handle the id, and "loc-" + the random tail of it
location.name as text
location.address_lines street, suite, "City, PR A1A 1A1" — empty while the address is unpublished
location.address the same lines joined with commas
location.city, location.province, location.place ("City, PR")
location.phone, location.phone_href (a tel: link)
location.service_area the area it serves, or nil
location.primary true for the primary location
location.has_map a pin, and the address published
location.map_url the OpenStreetMap embed for the pin (nil without one)
location.directions_url Google Maps directions to the pin (nil without one)
location.osm_url the pin on openstreetmap.org (nil without one)
location.hours_count, location.hours each open day: day (mon…sun), opens, closes (24-hour HH:mm)
Every map and directions address is built by the platform from the coordinates, never in Liquid. Its blocks are locations (a card per location: name, address or city, a phone link, the area served, the week's hours with closed days, directions) and location-map (an OpenStreetMap frame per mapped location, or the primary only — www.openstreetmap.org is already in a site page's frame-src).
File Requests takes files from the site. File requests are made under Operate → File requests in the portal, each with its own private upload link the owner e-mails to a customer; a request the owner marks Public on your website can also be answered from block send-files — the upload form for the public request whose title the block names, or the newest. It is a plain multipart form (name, files, an optional note, the shared honeypot) posting to POST /sites/{slug}/file-requests/{id}, and the visitor comes back to the page keyed on the block (?sent={block}, or ?formError={block}&reason=files-… with the reason in request.form_error_reason). Every file is typed from its bytes, must be a kind the request takes and is virus-scanned before it is kept; one bad file and none of the batch is stored. With no public request the block is a note that requests come by private link — a private link is never on a page. It asks for the files global and nothing else, and runs no script:
file_requests.count how many requests are offered
file_requests.honeypot the platform's hidden field, as markup
file_requests.items each open, public, not-full request, newest first:
item.id, item.title, item.instructions (plain text)
item.remaining files it still takes
item.max_mb the most one file may be
item.accept the picker hint (image/*,.pdf…) — the bytes decide
item.kinds the kinds in words ("photos and PDFs")
item.action where the form posts; nil on the canvas and the preview
item.inert true where action is nil
Careers lists the business's open jobs and freelance projects (block setting Show: jobs, projects or both). Jobs are written under Team → Careers in the portal; while one is open, block job-openings lists it with its place, job type, pay (only when the posting states it) and a short summary, linking to the job's page and its application form on the Abizvo careers board — the application, the resume and the applicant's private status page all live there, so the block holds no form. A job need not be listed on the public board to show on the business's own site. With nothing open a visitor reads the app's "when nothing is open" words and the editor a note. It asks for the careers global and nothing else, and runs no script:
careers.count how many jobs are open
careers.board_url the business's page on the careers board, or nil
careers.jobs each open job, newest first:
title, category, location, employment_type, workplace,
pay, summary, posted_at, posted_label, closes_on,
url, apply_url, kind ("job" or "project")
Analytics puts the business's own Google Analytics 4 measurement ID — and, optionally, a Google Tag Manager container and a Search Console verification token — on one site's published pages. It has no block and reads no global: the platform writes the verification meta into the head and a consent bar and a small same-origin loader at the foot of every published page, so nothing loads from Google until a visitor accepts, and nothing at all is drawn on the editor canvas or the preview (the app's one embed shows an editor-only note). Every value must be exactly the shape Google issues (G-…, GTM-…, the verification token's letters, digits, hyphens and underscores) or it is refused at save. Reports are read on the site's Analytics page in the portal: a link to the GA4 property, an embedded Looker Studio report and Abizvo's own visitor counts.
Social Links draws the business's social profiles as a row of the networks' own icons. The profiles are filled in once under Settings → Social profiles (a handle or a pasted link, each checked against the network's own hosts); block links shows the ones filled in — size, plain/outline/filled, a colour from the section's scheme, alignment and the networks' names on or off — each a link that opens in a new tab with rel="me noopener". Nothing filled in, nothing shown (a note in the editor). The icons are inline SVG from Simple Icons (LinkedIn as its "in" badge). It asks for the social global and nothing else, and runs no script:
social.count how many profiles are filled in
social.profiles each, in the platform's order:
network, label, url, icon (inline SVG, or nil),
has_icon, mark (the letter badge)
SEO has no blocks. Its one embed, structured-data (target head), writes the business as schema.org structured data — the type from the app's Kind of business setting, the name, phone, e-mail, logo and address from the business global (the address only where the owner publishes it), the home page's canonical address, and sameAs: the Google Business Profile link, the social handles under Business settings and each line of Other profiles, every one only when it is a full https address — plus <meta name="google-site-verification"> when a Search Console verification code is set. It asks for no global beyond business, site and app.settings, and runs no script (application/ld+json is data). In the portal, Website → SEO scores a site's pages out of 100 from the HTML the platform renders, keeps every run, and carries the Google Business Profile guide.
Loyalty shows the business's loyalty programme. The programme is run under Loyalty in the portal — off until the owner switches it on; points on every invoice paid in full, one per dollar before tax by default; a reward (100 points = C$5 by default) redeemed by the owner as a credit line on a draft invoice; points never expire. Block loyalty draws the rules in the programme's own numbers and a Check your points form: the Customer Portal's own e-mail-a-link form, posting to POST /sites/{slug}/portal-link (which answers a site with either app), so a known customer gets a fresh link to their account page, where the balance is. No balance is ever drawn on a website: the global carries rules and nothing else. While the programme is off the block draws nothing for a visitor and a note on the canvas. It asks for the loyalty and portal globals, and runs no script:
loyalty.enabled whether the programme is on
loyalty.points_per_dollar points a paid dollar (before tax) earns
loyalty.redemption_points points one reward takes
loyalty.redemption_value what a reward is worth, worded ("C$5.00")
The marketplace
Site apps sell under the Site apps category (abizvo-apps). A listing is a product with a listing, a release (the app's ZIP, abizvo-app.json at the root) and documentation, submitted for the same review a theme gets, with the app check as the artifact stage. Prices are free or C$5–C$300 in C$5 steps. A published release becomes a released marketplace version owned by the product, which a buyer's organization installs through its entitlement — and a free listing is free: its app installs on any site with no purchase at all, and the shop shows it as Free, never as a price of nothing. Apps have their own address on the shop, /apps and /apps/{slug}, where a page lists what the app adds (its blocks and embeds), what it reads from your business, how many settings it has and its README. Reviewed marketplace apps may run their script on their buyers' sites, as reviewed themes do.
The Scheduling app is listed there too, at C$0 — its listing is how it is found and read about. It is an official app, seeded on every host and installable on every site with no checkout, so publishing its listing imports no second copy: it records which product the app is sold as, and the seeder keeps owning its versions.