Skip to content

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.

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.

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.

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.

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
Print 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.

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.

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.

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 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.

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.

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.

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.

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.

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 first
select r.created_at, r.kind, r.reason, r.note, r.contact, r.proof, o.slug as org, b.slug as brand
from hub_reports r
join brands b on b.id = r.brand_id
join projects w on w.id = b.project_id
join organizations o on o.id = w.organization_id
where 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');

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.

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']);

/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.

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.

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.