Skip to content

The short form, service()

Most integrations are the same thing: call an endpoint or two, show some numbers. Say that, and service() does the rest.

data/integrations/speedbox.mjs
export default ({ service, fmt }) => service({
type: 'speedbox',
name: 'Speedbox',
icon: 'wifi',
auth: { header: 'X-Api-Key' },
get: { latest: '/api/results/latest' },
stats: d => [
{ label: 'Down', value: d.latest.down, unit: ' Mbps' },
{ label: 'Up', value: d.latest.up, unit: ' Mbps' },
{ label: 'Ping', value: d.latest.ping, unit: ' ms' },
{ label: 'Last run', value: fmt.day(d.latest.at) },
],
sample: { latest: { down: 940, up: 41, ping: 11, at: '2026-10-29T08:00:00Z' } },
expect: { Down: 940, Up: 41, Ping: 11 },
});

That file is a whole integration. From it you get:

  • The config: url, href, every, insecure, and the credentials auth asks for. All checked, with unknown keys refused.
  • Sign-in, including sessions and retries.
  • A widget called status, with a tile for the small sizes.
  • A read tool for the assistant, called speedbox_status.
  • A failure message that never includes the key.
config.yaml
integrations:
speed:
type: speedbox
url: http://speedbox:8080
key: ${SPEEDBOX_KEY}
layout:
- widgets: [speed.status]
FieldRequiredWhat it does
typeyesThe type: in config.yaml.
nameyesThe card’s title.
iconThe card’s icon, by name. pulse by default.
logoThe app’s own icon, by dashboard-icons name. The type by default.
authHow it signs in. See below. none by default.
authOptionalMany installs run without a login: send credentials only if they’re set.
optionsSettings of its own, as zod fields, beyond the URL and credentials.
everyHow often: seconds, or 5m. A minute by default. Use it for heavy calls.
getyesEach call to make, by name. A map, or a function of the options.
thenA second step that uses the first’s answers.
statsNumbers: { label, value, unit, sub, tone, pct, trend }.
listRows. Used when there’s no stats.
statusUp or down items. The tile counts them.
cardAnything the three above can’t say. Return any card.
headlineThe tile. The first stat otherwise.
aboutWhat the assistant is told it’s for, in a sentence.
recapSlides for the year in review.
sampleyesAn answer per get, as the real service sends it.
sampleOptionsOptions to use with the sample.
loginSampleWhat signing in answers, for a login that reads it.
expectLabel to expected value. The check asks for it.

stats wins if you give both it and list. With status, the card is a status card, and it says “2 down” or “9 up” by itself. A list says “Nothing here” when it’s empty.

Numbers in stats are formatted for you: 1200 shows as 1,200. A value that’s undefined shows as –, and the check flags it.

The keys you pick here become the keys of d in stats, and of sample.

get: {
stats: '/api/stats', // a plain path
jobs: { path: '/api/jobs', method: 'POST', body: { all: true } },
version: { path: '/version', as: 'text', optional: true },
},

An entry can be a path, or an object with:

FieldWhat it does
pathAdded to the configured url.
methodPOST. GET otherwise.
bodyAn object goes as JSON. A string or URLSearchParams goes as it is.
headersExtra headers.
astext for a plain answer, headers for the response’s headers.
optionalIf it fails, that key is undefined and the card still draws.

Everything else must succeed, or the widget shows the error.

get can be a function of the options, and it also receives session, the sign-in token, for services that want it in a body.

options: { org: z.string() },
get: o => ({ sites: `/v1/org/${o.org}/sites` }),

then runs after the first calls, and gets their answers:

get: { nodes: '/api/nodes' },
then: d => ({ first: `/api/nodes/${d.nodes[0].id}` }),
authWhat the config needsWhat’s sent
'none'Nothing.
'bearer'keyAuthorization: Bearer <key>
'token'keyAuthorization: Token <key>
'basic'username, passwordBasic auth.
{ header: 'X-Api-Key', prefix: '' }keyThat header. prefix is optional.
{ query: 'apikey' }keyIn the URL. The check makes sure errors don’t show it.
{ login: '/api/login', ... }username, passwordTrades them for a session.
{ handshake: { status: 409, header: 'X-Session-Id' } }optional username, passwordA refused first request hands out a session id, sent back on the next. Transmission does this.

A login can take more:

FieldWhat it does
loginThe path to post to.
bodyc => ({ ... }): what to send. JSON, unless it’s URLSearchParams. Username and password by default.
tokenr => r.jwt: reads the token from the answer. Without it, Valet uses the session cookie.
uset => ({ 'X-Auth': t }): how it’s sent back. Authorization: Bearer by default.
textThe answer is plain text, not JSON.
expireda => a.error === 'auth': for services that say “signed out” with a 200.
needsWhat it asks for: username, password, key.
redirectsThe cookie comes on a redirect, so don’t follow it.
ttlSeconds a token lasts, for services that don’t say.

A 401 or 403 resets the session and tries once more.

data/integrations/racks.mjs
export default ({ service }) => service({
type: 'racks',
name: 'Racks',
icon: 'cpu',
auth: 'bearer',
get: { nodes: '/api/nodes' },
status: d => d.nodes.map(n => ({ name: n.name, ok: n.online, detail: n.online ? undefined : 'offline' })),
sample: { nodes: [{ name: 'nas', online: true }, { name: 'pi', online: false }] },
expect: { nas: true, pi: false },
});
data/integrations/shelfy.mjs
export default ({ service, z }) => service({
type: 'shelfy',
name: 'Shelfy',
icon: 'book',
auth: { login: '/api/login', token: r => r.jwt },
options: { limit: z.number().int().default(5) },
every: '5m',
get: { books: '/api/books?sort=recent' },
list: (d, o) => d.books.slice(0, o.limit).map(b => ({ title: b.title, subtitle: b.author })),
sample: { books: [{ title: 'A Slow Winter', author: 'Sam Okafor' }, { title: 'Salt Road', author: 'Alex Lind' }] },
loginSample: { jwt: 'tok' },
expect: { 'A Slow Winter': 'Sam Okafor', 'Salt Road': 'Alex Lind' },
});

expect finds each row by its label, name or title, and compares its value, meta, subtitle or ok, in that order.

insecure: true in the config accepts a self-signed certificate, for that service only. Every service() takes it.

Paste a real answer, then remove anything personal. sample is what the tests use forever. npm test runs every built-in against its own, and check-integration does the same for yours.

A sample can’t prove the service still answers that way. It proves your code reads that answer. If you have the real service, npm run test:services checks against it.

For several widgets, buttons, or tools that change things, use the full form. You can start from a template:

Terminal window
docker exec valet node dist/server.js new-integration my-service --full