Portals
A portal is a press kit, a partner hub or a retailer page: the brand pages and
collections you pick, under your logo and colors, at /p/{address} or a domain
of its own. A brand shared with nobody yet has Share publicly on its
Sharing tab: one click releases it if it never was, makes a public portal
showing it and, when asked, lists it on BrandHub. Make one from Sites in the sidebar, with
POST /api/v1/sites, or have an agent make one with create_site (open to
the project’s members, to whoever has its password, or to anyone, with its
logo and colors) and change one with update_site. It
takes write on the project, and sharing rights on each collection. A portal
shows at least one collection or one brand. A portal is one kind of
site: a site you build yourself deploys as static files, and
can mount beside a portal’s pages, at a path such as /docs.
What shows
Section titled “What shows”Only what may be used: approved, unexpired, out of embargo, and a stack’s
current version. The same rule as /a/{id}, so an asset that expires, gets
archived or is replaced by a new version leaves every portal the moment that
happens. Nobody has to remember to take it down.
Collections make the portal’s Assets view (?view=assets), where
visitors search, filter by collection, look closer, and download. To show a
collection inside the guidelines instead, add a Collection section to a
brand page.
Brand pages
Section titled “Brand pages”A portal shows the brand pages of each brand
it carries, in its own theme: the tree of pages in a nav column, the page, and
a switch between brands. Brands go in by slug, in order
("brands": ["default", "sub-brand"]).
Visitors read a brand’s latest release, never the draft. Edit as much as you like: nothing reaches a portal until someone releases it, and a brand that was never released shows nothing at all. The Sites page says so beside each brand: the version visitors read and when it was released, or “Not released”. Releasing answers with the portals now showing the brand.
A rule’s assets show by the same rule as everything else, so a draft logo stays in the library, and every asset is signed for the visitor.
| Address | What it opens |
|---|---|
/p/{address} |
The first brand’s first page |
/p/{address}/{page} |
A page of the first brand |
/p/{address}/{brand}/{page} |
A page of another brand |
?view=updates |
What’s new: each release, its note, and what it changed |
?view=book |
Every page in order, to read through or print |
Moving a page in the tree never changes its address, and a renamed page’s old
slug leads to its new one. Pages show when they were last updated, and the
footer when the brand was last released. / opens search, across the pages,
the rules and the portal’s assets.
The API serves the same, behind the same door as the portal:
GET /api/v1/portal/{address}/site?path=, /search?q= and /updates?brand=.
GET /api/v1/portal/{address}/brands/{slug} still answers with the brand’s
rules, as last released.
Releasing and sharing are two steps: a release is the version readers get, a portal the address they read it at, with who gets in and its look. One brand can be on several portals, and a portal can show several brands. A brand no portal shows yet gets one in the same step as its release, if you like: the release form offers a portal named for the brand, open to the project’s members or to anyone with the address.
A portal’s menu on the Sites page takes it offline at once (its address
says it is closed, and nothing about it is lost), brings it back, or deletes
it. Over the API, POST /api/v1/sites/{id}/close takes it offline and
PATCH with expiresAt: null reopens it.
Who reads what
Section titled “Who reads what”A page or a section can be for fewer readers than the whole portal:
| Audience | Who reads it |
|---|---|
everyone |
Anyone the portal lets in (the default) |
partners |
Visitors let in by the password or an approved request, and members |
members |
People signed in with access to the project |
What is above a visitor is left out before anything is sent or signed. A
locked page still shows in the nav, with a lock and a way in: Ask for
access or Sign in. A public portal carrying partner or member pages
takes access requests too. Set the audience on a page or a section, in the
app or with save_page and edit_page.
Downloads
Section titled “Downloads”Images download as renditions made for a purpose, not the raw original:
| Preset | What they get |
|---|---|
| Web | JPEG, 1920 px wide |
| Social | JPEG, 1080 px square |
| Story | JPEG, 1080 × 1920 |
| JPEG, full size | |
| PNG | Full size, keeps transparency |
| Original | The file as uploaded, with its metadata written in |
Web, Print and Social unless you pick others. Anything that isn’t an image (a PDF, a video, a font) downloads as itself. Each file’s card lists its presets, a download each.
A file shown only (its rights: a licensed photo, a foundry’s typeface) has none: visitors see it, and a portal’s pages still set its typeface, but nothing offers it to take. A portal that lets in more than the project says so where its door is set.
On each card and beside a file’s downloads, Can I use this? asks its rights for a use, a
country and a day (the check, behind the portal’s door:
POST /api/v1/portal/{slug}/check). It only answers about files the portal
shows, and counts in Insights as the portal’s.
A logos section’s kit (every version of each mark in one zip) comes with the brand’s PowerPoint and Word templates, from its latest release: its colors, fonts and logo, ready for a deck or a letter. Turning the kit off on the section takes them off too.
The portal’s header names what it shows: Guidelines (each brand by its
name, when it carries several), Assets, What’s new (?view=updates)
and Contact, the footer’s feedback address when one is set.
Who gets in
Section titled “Who gets in”| Access | Who |
|---|---|
| Anyone with the address | Everyone. Search engines are asked to stay out, unless it is listed. |
| Whoever has the password | Ten wrong guesses in ten minutes from one address, then it waits. |
| People in this project | Signed in, with access to the project. |
A portal can close on a date: after it, the address answers 410.
On the last two, anyone else can ask for access: their email, a name, and
a note. The project’s admins get an email, and every site’s requests wait
in Inbox, Requests (GET /api/v1/sites/requests). Approve, and they get a link of
their own by email (and one to copy, if email is off), good for 90 days or
until the portal closes. Remove the request to take it back.
Requests don’t stay forever: the sweeper deletes one, with its email, name and note, 90 days after nothing waits on it. That is 90 days after it came if nobody answered, after it was answered, or, for access approved, after its link stopped working.
A logo (an approved image from the library), an accent color for buttons and links, and a header background. They dress the portal’s header, its door and its Assets view; the pages wear their brand’s own theme.
A portal made for a brand wears its look: an empty logo is the mark of the
first brand it shows (its logo rule named mark, icon or symbol, else its first
logo with an image) and an empty accent its color.primary (else its first
color), from the release visitors read, so the portal follows the brand as it
changes. The form shows them as “From” and the brand’s name. Set either to override it, for
a white-label portal. With no brand to borrow from, the organization’s own.
A brand’s Sharing tab makes a portal for it in the same form, the brand picked, and lists the portals showing it.
Around the pages
Section titled “Around the pages”The portal’s site holds what sits around the pages, set under Header
links, footer and terms in the portal’s form, or in the API, where it replaces the whole set:
{ "site": { "quick": [ { "label": "Logo pack", "asset": "7b1d0e3c-..." }, { "label": "Colors", "page": "color" }, { "label": "Press office", "href": "mailto:press@example.com" } ], "footer": { "text": "Questions about the brand: **brand@example.com**", "links": [{ "label": "Newsroom", "href": "https://example.com/news" }], "credit": "Design by Studio North", "feedback": "mailto:brand@example.com" }, "terms": "For editorial use about Example only.", "listed": true }}- Quick grab: up to six links pinned in the header. Each opens a page (of
brand, or the first), downloads an asset, signed for the visitor while it may be used, or goes to an address. - Footer: Markdown, up to eight links, a credit line and where feedback goes. The footer also says when the brand was last updated, and links to What’s new.
- Terms: Markdown visitors accept once, before their first download. It is a courtesy, not a lock: each file is still signed on its own.
Links go to https://, mailto: or a /path, never a script.
Languages
Section titled “Languages”A brand’s theme lists the languages its pages come in
("languages": [{ "code": "en", "label": "English" }, { "code": "ar", "label": "العربية" }]);
the first is the one they are written in. Pages and sections carry their words
in the others under translations, and a visitor who picks one (?lang=ar)
reads it field by field, falling back to the first language where a
translation is missing. A brand with a context named after the language (ar)
resolves its rules for it too.
Right-to-left languages turn the whole page, and fonts whose rule names the language’s script lead the type.
Print and the book
Section titled “Print and the book”Print this page prints the page without the portal around it: colors as they are, every tab’s content under its name, and the address of each link that leaves the portal. Print the book prints every page the visitor may read, in order, with a table of contents first. Both are the browser’s print, so saving a PDF works the same way.
Search engines
Section titled “Search engines”Portals are kept out of search: every page asks not to be indexed, and each
portal’s robots.txt turns crawlers away. A public portal can opt in with
Let search engines list it ("listed": true): its pages may be indexed,
titled after the page and its brand, with the page’s lede as the description
and its cover as the preview image. A portal behind a password or for members
is never listed.
BrandHub
Section titled “BrandHub”A server with HUB_URL set runs BrandHub. Every released brand is on it at
{organization}/{brand}, private by default: only people who can read its
project see it, signed in. Make public on the brand’s Sharing tab or the Brands page (or
PATCH /api/v1/brands/{slug}/hub with "visibility": "public", or the
set_brand_hub tool) and anyone, and any agent, reads it. Either way BrandHub
shows the brand’s latest release, with only the assets that may be used, so a
brand goes public once it has been released, and later releases show there
as they happen.
A brand needs no portal to be on BrandHub. When a portal shows it, BrandHub
links that portal as its guidelines: its first public one, or the one picked
on its row of the brand’s Sharing tab, where every portal showing the brand
is listed with who gets in and the requests waiting ("portal").
With a host of its own (HUB_URL=https://hub.example.com), BrandHub serves
public brands there, to anyone. When that host sits beside the app’s
(hub.example.com and app.example.com), the app’s sign-in reaches it: the
session cookie is set for example.com, and people signed in see their
private brands there too. On a host that shares no domain with the app’s,
they find them on the app’s own address, at /hub. BrandHub, at the end of
the app’s sidebar, opens whichever of the two that is, in a new tab.
A brand’s address is {org}/{brand}, and @{n} pins a version:
| Address | What it is |
|---|---|
/{org}/{brand} |
Its page: colors, type, logos, voice, terms, and a link to its portal |
/{org}/{brand}/llms.txt |
The brand in words, for an agent |
/{org}/{brand}/brand.json |
The brand as an AdCP brand.json: logos, colors, fonts, voice, don’ts (the mapping) |
/{org}/{brand}/rules.json |
Every rule, its files as URLs signed for a day |
/{org}/{brand}/tokens?format=css |
Design tokens, in any format of GET /api/v1/brand/tokens (formats); &fonts=none leaves out the @font-face rules for a site that serves its own fonts |
/{org}/{brand}/press-kit.zip |
The press kit: every logo (an SVG with a PNG beside it), the palette (colors.txt, colors.ase for Adobe apps), tokens.css and tokens.json, the font files their license lets anyone pass on, and a README with the release, its source, each part’s use and the brand’s do’s and don’ts |
/{org}/{brand}/badge.svg |
A README badge in the brand’s color, with its mark and name, the release that is live and a check when verified (“Acme @4 ✓”), not counted as a read |
/{org}/{brand}/og.png?title= |
Its share card, a 1200x630 Open Graph image of its release, for any page’s og:image (share cards), not counted as a read |
/index.json?q= |
The public brands, or what a search finds |
/llms.txt |
How an agent finds a brand here, and the list |
The files answer for public brands only, but the press kit. Anyone may make
public a brand of any name, so a page names who has it. An organization with
a verified domain or a verified GitHub account gets a
badge with it; any other public brand is a community one, says so, and is
kept out of search engines (noindex on its page, X-Robots-Tag: noindex on
its files, and left out of the sitemap).
Download press kit on a brand’s page gets the press kit, and so does a
private brand’s own people, signed in. It holds only what the page shows:
files that may be used, less those whose owner doesn’t hand them out (a
foundry font, a licensed mark), which its README names instead, as it names
what would take the zip past 100 MB. A community listing’s README says it
may not come from the brand’s owner, with what its first release said of
where it came from. The kit changes only when the release, a file in it or
its words do, so it carries an ETag, and caches keep it as long as the
other files.
Verified by GitHub
Section titled “Verified by GitHub”Beside a domain, an organization can prove it runs a GitHub organization, in
Settings, BrandHub (POST /api/v1/github-orgs), which opens on whether
its listings are verified and by which domains and accounts. Put the file it names,
artbucket-verification.txt holding the token shown, in the account’s
.github repository on its default branch, then Check now
(POST /api/v1/github-orgs/{login}/verify). The server reads it from
raw.githubusercontent.com, following no redirect. Once proved, the
organization’s listings show as verified by github.com/{login}. Like a
domain, one left unproved for 7 days may be named by another organization.
Report, claim and delist
Section titled “Report, claim and delist”Every public listing has Report: anyone, signed in or not, says what is
wrong (POST /api/v1/hub/{org}/{brand}/reports: a reason, a note, and a way
to reach them if they want). A community listing also has It’s my brand:
an admin of an organization that proved a domain or a GitHub account claims
it (POST /api/v1/hub/{org}/{brand}/claims), and the claim names what they
proved and their email. Nothing of either is public, and no IP address is
kept: an address only counts toward a few reports an hour.
Both land in the listing’s organization’s Settings, BrandHub
(GET /api/v1/hub/reports), where its admins resolve them, reach a claimant
and hand the brand over (the claimant can start from it, below), or Take off
BrandHub, which makes the brand private (PATCH /api/v1/hub/reports/{id}
with "delist": true). The organization can always take its own brand off
BrandHub the same way on the Brands page.
Claim by domain
Section titled “Claim by domain”A public listing may name its brand’s domain (its Settings, or its
brand.json). An organization that verifies that domain, a name
under it (brand.acme.com proves acme.com) or one above it, is offered the
listings of other organizations that never proved it: on the Brands page (“Claim
Test on BrandHub has your domain acme.com: make it yours”) and in Settings,
BrandHub (GET /api/v1/hub/offers). Never across a public suffix: a name
under a zone that hands out names to others (acme.github.io, acme.vercel.app,
from the Public Suffix List) proves itself and the names under it, not the zone
or a sibling. Such a listing reads Unclaimed · Is
this yours? Prove acme.com to claim it when its organization proved nothing.
Make it yours (POST /api/v1/hub/offers/{org}/{brand}, {"slug"} to name
it otherwise) starts a brand in your project from the listing’s release, as
Start from this brand does, with its domain, released and public. The
listing goes private, its page and files answer 308 to yours for good, and it
can’t be made public again; its organization keeps its brand, finds a resolved
claim in Settings, BrandHub, and an entry in its audit log. No brand moves
between organizations. Not ours (DELETE on the same path) stops the offer.
Anything else, a listing with no domain or one whose organization proved it,
goes through It’s my brand above.
Whoever runs the server reads every report in the database, and can delist a
brand for good: while hub_delisted holds a reason, the brand is off BrandHub
and its organization can’t make it public again, which says why.
-- Open reports and claims, newest firstselect r.created_at, r.kind, r.reason, r.note, r.contact, r.proof, o.slug as org, b.slug as brandfrom hub_reports rjoin brands b on b.id = r.brand_idjoin projects w on w.id = b.project_idjoin organizations o on o.id = w.organization_idwhere r.status = 'open'order by r.created_at desc;
-- Delist acme/firefox (setting null lists it again: its organization may then make it public)update brands set visibility = 'private', hub_delisted = 'A trademark complaint from its owner'where slug = 'firefox' and project_id in (select w.id from projects w join organizations o on o.id = w.organization_id where o.slug = 'acme');Start from this brand
Section titled “Start from this brand”Signed in, Start from this brand on a public listing makes a new brand in
the project you have open from that release: its rules, its theme, the pages
and sections anyone may read (none hidden, or for partners or members), and
the files they use that may be delivered, copied into your library (the same
bytes, stored once). A file the listing shows but doesn’t hand out, a foundry
font or a licensed photo, isn’t copied: its rule starts without it, and
skipped names it. The new brand is yours to edit, with a history of its
own, and keeps where it came from as from. An agent does the same with
create_brand and from: "rust-lang/rust@12", or POST /api/v1/brands with
{"name": "...", "from": "rust-lang/rust@12"}; leave out @12 for the
latest release. A claimant a listing is handed over to starts from it the
same way, and takes every file, since the domain proved them its owner.
Pulls, follows, trending and collections
Section titled “Pulls, follows, trending and collections”A listing’s pulls are its files (brand.json, llms.txt, tokens) read
in the last 30 days, the same reads Insights counts. Its
card, its page and the brand’s Overview show them (“1.2k pulls”), and
index.json and GET /api/v1/brands/{slug}/hub carry them as pulls.
Someone signed in can Follow a public listing
(PUT /api/v1/hub/{org}/{brand}/follow, DELETE to stop): BrandHub’s front
page then has a Following tab with the brands they follow. Trending
this week, the order it opens in, sorts by pulls in the last 7 days. A
community listing’s card says it may not come from the brand’s owner, and
the front page ends with a domain box for the public Brand Agent Score.
Curated collections, like “Open-source project brands”, are the server
operator’s: each is a row in hub_collections, listing public brands as
{org}/{brand} in order, and shows as a section on BrandHub’s front page
(lowest position first; a brand that isn’t public is left out).
insert into hub_collections (slug, title, description, position, brands)values ('open-source', 'Open-source project brands', 'Projects that share their brand in the open.', 0, array['blender/blender', 'mozilla/firefox', 'rust-lang/rust']);A public Brand Agent Score
Section titled “A public Brand Agent Score”/score on BrandHub checks any domain the way an agent would find the brand
there, for anyone, and scores it out of 100 on the scale of the Brand Agent
Score in the app: colors (15), typefaces (15), a logo with its file (20) and a
voice (15), read from the brand’s rules; rules a machine reads (15), a
brand.json its llms.txt links or at /.well-known/brand.json, or a public
BrandHub listing of an organization that verified the domain; an llms.txt at its root (10);
design tokens (5); and an MCP server its llms.txt names that answers (5).
The report lists what raises the score, and offers to list the brand here.
A stranger sets it off, so it is bounded: at most four fetches per domain, each to public addresses only, 256 KB and 6 seconds at most; a report is kept five minutes; and each address may ask ten times in ten minutes, everyone together two hundred. The address is never stored.
A subdomain for every portal
Section titled “A subdomain for every portal”With PORTAL_DOMAIN set, say portals.example.com, every portal
answers at {address}.portals.example.com, with nothing to claim or prove:
the server owns the domain. That is the address the app shows and copies,
but for a members portal, which stays at /p/{address} (sessions belong
to the app’s own address), and a portal with a verified domain of its own.
/p/{address} redirects there for good, so links shared before keep working.
Renaming a portal keeps its old address, /p/ and subdomain alike, leading
to the new one, and nobody else may take it while the portal stands. It keeps
its last five; an older one, and every one when the portal is deleted, is
free again. While you type an address, the form says whether it is
free (GET /api/v1/sites/address?slug=...). Some addresses are kept
for the service, like www, api or mail, and so is any with dashes as its
third and fourth characters (xn--, the shape of a look-alike name).
It takes a wildcard DNS record, *.portals.example.com, pointing at the
server, and a wildcard certificate, or on-demand TLS as below:
/api/v1/domains/check answers 200 for a subdomain whose portal exists.
On a domain of its own rather than a subdomain of the app’s, a portal can’t
touch the app’s cookies; list it on the Public Suffix
List too, so portals can’t touch each other’s.
A domain of its own
Section titled “A domain of its own”Domains are the organization’s, like press.example.com, added in Settings,
Domains or, by an organization admin, from the site itself: Add a domain
under its Domain, verified there and picked for it at once. Each shows a TXT record to add at your DNS host, which
proves the domain is yours:
_artbucket-challenge.press.example.com TXT artbucket-3f2a...Point the domain itself at your server (a CNAME to its host name, or an A
record), then Check now. With DOMAIN_TARGET set, the exact CNAME is
shown, and checked along with the TXT record; at a zone’s apex, where a CNAME
can’t go, an ALIAS or flattened record to the same target works.
A verified domain serves nothing until you choose what it is for. Turn on
Use for the app beside it in Settings, Domains, and the whole app answers
there; any number may, and Make default picks the one links in email
point at. Or pick it in a portal: Domain of its own lists the
organization’s verified domains, and the domain then serves that portal and
nothing else of the app. A domain the app used stops serving it when a portal
takes it.
Removing a domain in Settings sends its portal back to /p/{address}.
Keep the TXT record: a domain whose record is gone for 7 days is unverified,
as for the app, and its
portal stops answering there until it is proved again.
How many domains an organization may add is the operator’s domains
limit.
The server speaks plain HTTP behind a reverse proxy. For certificates on domains you don’t know in advance, let the proxy ask the app first. With Caddy:
{ on_demand_tls { ask http://artbucket:3000/api/v1/domains/check }}
assets.example.com { reverse_proxy artbucket:3000}
https:// { tls { on_demand } reverse_proxy artbucket:3000}GET /api/v1/domains/check?domain=... answers 200 only for a verified
domain, so nobody can make your server request certificates for names it
doesn’t serve.