Developers · API & CLI

Your screens, scriptable.

Manage screens, playlists and the library from the terminal, from scripts, or from an AI assistant such as Claude Code, Codex or ChatGPT. The ztalio CLI and the REST API are included with the Pro and Network plans; create a key in Settings and you are in.

26 endpoints120 requests/min per key3 scopes: read, write, push

Three commands to a screen.

A key from Settings, the CLI from npm, and the first push lands on the TV within seconds.

STEP 1

Create a key

Sign in at ztalio.com → Settings → API access → Create API key. Pick its scopes. The key is shown once; copy it.

# looks like
ztk_…
STEP 2

Install and sign in

Node 18 or newer. login stores the key in ~/.config/ztalio/config.json (mode 600).

npm install -g ztalio && ztalio login
STEP 3

Look, then push

Screens and playlists go by id or by part of their name; case and punctuation do not matter.

ztalio screens
ztalio push "Happy Hour" --to Lobby
# the same thing over plain HTTP
curl -H "Authorization: Bearer ztk_…" https://api.ztalio.com/v1/screens

Use it with an AI assistant.

Any agent with a shell can drive the CLI: Claude Code, Codex, ChatGPT with a terminal, or your own. Install it, sign in, say what you want on which TV.

ztalio agent

The guide an assistant reads first.

ztalio agent prints how the tool is meant to be used. Point the assistant at it, or paste it into its instructions. An excerpt:

You control a Ztalio account's TV screens. Everything is
idempotent and safe to retry; add --json to every command
and parse stdout. Typical flow:

1. ztalio screens --json     → which TVs exist, online, showing
2. ztalio media --json       → the library (title, mediaType, s3Key)
3. ztalio playlists --json   → existing playlists
4. Make or edit a playlist:
     ztalio playlists create "Lunch specials"
     ztalio playlists add "Lunch specials" "Tacos.jpg" "Burger.jpg"
     ztalio playlists set "Lunch specials" --speed 8 --transition dissolve
5. Put it on the screen:
     ztalio push "Lunch specials" --to "Lobby"   (updates in seconds)
6. New content:
     ztalio upload ./menu.png --folder Promos   (~1 min, then in media)

Before a push, confirm with the user which screen(s) and
which playlist. Never push to --all unless the user said
"all screens".
Example

Put the lunch menu on the lobby TV at 8 and the dinner menu at 4

The assistant finds the Lobby screen and the two playlists, gives each a schedule in your account’s timezone, and pushes. Roughly:

ztalio screens --json
ztalio playlists set "Lunch menu" --schedule "8-16"
ztalio playlists set "Dinner menu" --schedule "16-23"
ztalio push "Lunch menu" --to Lobby
  • Assistants confirm the target screen before pushing, and never use --all unless you said all screens.
  • Keys are scoped (read, write, push) and revocable from Settings at any time; a read-only key cannot change a TV.
  • Errors come back as JSON with a code, so an agent knows whether to ask you to sign in, wait a minute, or upgrade.

Or connect over MCP

The same sixteen tools are an MCP server — locally through the CLI, or hosted with no install. Your API key is the only credential, so scopes, limits and the audit trail apply exactly as for the REST API.

# Claude Code, local (uses the key from `ztalio login`)
claude mcp add ztalio -- ztalio mcp

# Claude Code, hosted
claude mcp add --transport http ztalio https://api.ztalio.com/v1/mcp --header "Authorization: Bearer ztk_…"

# Claude Desktop → Settings → Developer → Edit config, under "mcpServers"
"ztalio": { "command": "ztalio", "args": ["mcp"] }

claude.ai: Customize → Connectors → Add custom connector → URL https://api.ztalio.com/v1/mcp, Authentication “No sign-in”, request header authorization = Bearer ztk_…. Cursor, Codex and other MCP hosts: command ztalio, args ["mcp"]. ztalio mcp --setup prints all of this. Tools: whoami, list_screens, rename_screen, list_playlists, get_playlist, create_playlist, add_to_playlist, remove_from_playlist, update_playlist, delete_playlist, push_playlist, list_media, list_folders, list_slide_templates, create_slide (with preview the picture comes back inline), screen_analytics.

The CLI.

A zero-dependency Node client over the REST API. Source on GitHub, package on npm, MIT licence.

npm install -g ztalio
ztalio login            # paste a key from ztalio.com → Settings → API access
ztalio screens          # your TVs: online/offline, what's playing
ztalio push "Happy Hour" --to Lobby

Account

ztalio login [--key ztk_…]save your API key (Settings → API access; Pro & Network)
ztalio whoamiaccount, plan, usage

Screens

ztalio screensyour TVs: online/offline, what's playing
ztalio screens rename <screen> <name>rename a screen
ztalio push <playlist> --to "Lobby,Bar 2"(or --all) the TVs change within seconds

Playlists

ztalio playlistslist playlists
ztalio playlists show <playlist>items, speed, transition, shuffle, music, schedule
ztalio playlists create <name>create a playlist
ztalio playlists add <playlist> <item…>items by title (see ztalio media) or key
ztalio playlists remove <playlist> <item…>remove items
ztalio playlists set <playlist> --speed 7 --transition dissolve --shuffle on --music "Tokyo Dreams"speed in seconds (2–3600); transition none, dissolve, slide, flip or zoom
ztalio playlists set <playlist> --schedule "mon-fri 8-17"hours in your account timezone; "off" clears
ztalio playlists delete <playlist>delete a playlist

Library

ztalio media [--folder <name>] [--type image|video|audio]the library: title, type, key
ztalio upload <file…> [--folder <name>]upload files; they appear in the library within a minute
ztalio slide --template promo --title "Happy hour" --badge "$5 margaritas" --push Lobbya slide from text — rendered on the server with the Studio engine, saved to the library, optionally pushed
ztalio slide --items "Tacos=$4|street style; Burrito=$9 *Popular" --title Lunch --add-to "Lunch menu"menu board or price list from name=price pairs
ztalio slide --prompt "…" --preview check.pnglet the Studio assistant lay it out; preview renders without saving
ztalio slide --templatesthe eight templates and the fields each accepts
ztalio folders | folders create <name>list or create folders
ztalio musicbuilt-in background tracks

Raw API & assistants

ztalio api <METHOD> </path> [--data '{…}']raw call to https://api.ztalio.com/v1
ztalio agenthow an AI assistant should use this tool

Names, not ids. Screens, playlists, folders and media can be named by id or by part of their name; case and punctuation do not matter (beachsunset finds “Beach Sunset”).

Machine output. Add --json to any command for JSON on stdout. Errors go to stderr as JSON with a code (NO_KEY, UPGRADE_REQUIRED, SCOPE, RATE_LIMITED, NOT_FOUND).

Environment. ZTALIO_API_KEY overrides the saved key (handy in CI and containers), ZTALIO_API_BASE changes the API base, ZTALIO_CONFIG_DIR moves the config file. Global flags: --json, --key, --base.

The REST API.

Manage your Ztalio screens, playlists and library from scripts and AI assistants. Plain JSON over HTTPS; the CLI is a thin client over it. The live document is at api.ztalio.com/v1/openapi.json.

Base URL
https://api.ztalio.com/v1Version 1.0.0. Every path below is relative to it.
Auth
Authorization: Bearer ztk_…A key from Settings → API access. Keys are shown once and can be revoked there.
Scopes
read · write · pushChosen when the key is created. read lists, write edits playlists, folders and uploads, push changes what a TV shows.
Rate limit
120 requests / minutePer key. Over it, the API answers 429 RATE_LIMITED; wait a minute.
API error responses by HTTP status and code
StatusCodeMeaning
401NO_KEY · BAD_KEY · REVOKED_KEYNo Authorization header, the key does not exist, or it was revoked in Settings.
402UPGRADE_REQUIREDThe plan behind the key does not include API access (Pro and Network do).
403SCOPEThe key lacks the scope the endpoint needs (read, write or push).
404NOT_FOUNDNo such screen, playlist, folder or upload on this account.
429RATE_LIMITEDMore than 120 requests in a minute on this key. Wait a minute and retry.
502UPSTREAMA Ztalio service behind the API failed. Safe to retry.

Every error body is { error, code }. Successful calls answer 200 with JSON; the shapes are listed per endpoint below and in the objects at the end.

Account

GET/mescope · read

The account behind this key

Returns
200Account — The account behind this key401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)

Screens

GET/screensscope · read

List screens

Returns
200{ screens: Screen[] }401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
GET/screens/analyticsscope · read

Screen uptime analytics (same data as ztalio.com/analytics)

Path
days integer · query — window in days (1–30)
Returns
200OK401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
POST/screens/{id}/renamescope · write

Rename a screen

Path
id string · pathrequired
Body
name stringrequired
Returns
200{ success: boolean } — Renamed401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
POST/screens/{id}/pushscope · push

Push a playlist (or ad-hoc items) to one screen; the TV changes within seconds

Path
id string · pathrequired
Body
Either playlistId or items[]
playlistId stringitems object[]items[].key stringrequireditems[].type "image" | "video"items[].title stringtransition "none" | "dissolve" | "slide" | "flip" | "zoom"slideSeconds integer — 2–3600
Returns
200PushResult — Pushed400Empty playlist / bad body401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Screen not found429Error — Rate limited (120/min per key)
POST/pushscope · push

Push to several screens

Body
Either playlistId or items[]
playlistId stringitems object[]items[].key stringrequireditems[].type "image" | "video"items[].title stringtransition "none" | "dissolve" | "slide" | "flip" | "zoom"slideSeconds integer — 2–3600screenIds string[]required
Returns
200PushResult — Pushed401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)

Playlists

GET/playlistsscope · read

List playlists

Returns
200Playlist[]401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
POST/playlistsscope · write

Create a playlist

Body
name stringrequired
Returns
200{ playlistId: string, name: string } — Created401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
GET/playlists/{id}scope · read

Get a playlist

Path
id string · pathrequired
Returns
200Playlist401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
PATCH/playlists/{id}scope · write

Update name, speed, transition, shuffle, music or schedule

Path
id string · pathrequired
Body
name stringslideSeconds integer — 2–3600transition "none" | "dissolve" | "slide" | "flip" | "zoom"shuffleEnabled booleanbackgroundMusicS3Key string | nullschedule object | null
Returns
200Playlist — The updated playlist401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
DELETE/playlists/{id}scope · write

Delete a playlist

Path
id string · pathrequired
Returns
200{ deleted: boolean } — Deleted401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
POST/playlists/{id}/itemsscope · write

Append library items (by s3Key)

Path
id string · pathrequired
Body
s3Keys string[]required
Returns
200{ added: integer, itemsCount: integer } — Appended401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
DELETE/playlists/{id}/itemsscope · write

Remove items (by s3Key)

Path
id string · pathrequired
Body
s3Keys string[]required
Returns
200OK401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)

Library

GET/mediascope · read

The library feed: folders and items

Returns
200{ folders: { id: string, name: string, itemCount: integer }[], items: MediaItem[] }401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
GET/foldersscope · read

List folders

Returns
200{ items: Folder[] } — Folders you own or were granted401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
POST/foldersscope · write

Create a folder

Body
name stringrequiredparentFolderId string
Returns
200Folder — Created401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
GET/folders/{id}/itemsscope · read

Items in a folder

Path
id string · pathrequired
Returns
200{ items: MediaItem[] } — Items in the folder401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
GET/musicscope · read

The built-in background-music library

Returns
200{ tracks: Track[] } — The built-in tracks401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)

Slides

GET/slides/templatesscope · read

The eight slide templates and the fields each accepts

Returns
200{ templates: { id: string, name: string, blurb: string, size: object, fields: object }[] } — Templates401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
POST/slides/previewscope · read

Render a slide and return the PNG (base64) without saving it

Body
Either template + fields, or a complete Studio doc.
template "menu-board" | "price-list" | "promo" | "announcement" | "hours" | "welcome" | "event" | "portrait-menu" — default "announcement"fields SlideFieldsdoc object — a complete StudioDoc (as returned by /slides/propose)name stringplaylistId string — append the slide to this playlistscreenIds string[] — push the slide to these screens (needs the push scope)
Returns
200{ png: string, size: object, bytes: integer, doc: object } — The PNG400Error — Bad request401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
POST/slides/proposescope · write

Let the Studio assistant lay a slide out from a sentence (same quotas as ztalio.com/studio)

Body
prompt stringrequiredtemplate stringfields SlideFields
Returns
200{ doc: object, reply: string, turnsLeft: integer } — A complete Studio doc to pass to POST /slides as `doc`400Error — Bad request401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope422Error — The assistant did not produce a slide429Error — Rate limited (120/min per key)
POST/slidesscope · write

Make a slide from text — a menu board, promo, announcement, hours, welcome or event — and save it to the library (folder "Studio"); optionally add it to a playlist and push it to screens

Body
Either template + fields, or a complete Studio doc.
template "menu-board" | "price-list" | "promo" | "announcement" | "hours" | "welcome" | "event" | "portrait-menu" — default "announcement"fields SlideFieldsdoc object — a complete StudioDoc (as returned by /slides/propose)name stringplaylistId string — append the slide to this playlistscreenIds string[] — push the slide to these screens (needs the push scope)
Returns
200SlideResult — Saved400Error — Bad request401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)

Uploads

POST/uploadsscope · write

Start an upload (S3 multipart). Then PUT each part to its URL and call complete.

Body
filename stringrequiredcontentType stringrequiredsizeBytes integerrequiredtotalParts integerrequired — parts of up to 100 MBfolderId string — default "root"
Returns
200{ uploadId: string, s3Key: string, totalParts: integer, contentType: string, folderId: string } — Upload started401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope429Error — Rate limited (120/min per key)
POST/uploads/{uploadId}/part-urlsscope · write

Presigned PUT URLs for parts

Path
uploadId string · pathrequired
Body
partNumbers integer[]required
Returns
200{ uploadId: string, parts: { partNumber: integer, url: string }[] } — Presigned PUT URLs401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
POST/uploads/{uploadId}/completescope · write

Finish the upload; the file is optimised and appears in the library within a minute

Path
uploadId string · pathrequired
Body
parts object[]requiredparts[].partNumber integerparts[].etag string
Returns
200{ uploadId: string, s3Key: string, folderId: string, location: string } — Completed; the file is optimised and appears in the library within a minute401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)
DELETE/uploads/{uploadId}scope · write

Abort an upload

Path
uploadId string · pathrequired
Returns
200OK401Error — Missing, unknown or revoked key402Error — Plan without API access403Error — Key lacks the scope404Error — Not found429Error — Rate limited (120/min per key)

Objects

Screen

  • idstring
  • namestring
  • status"paired" | "unlinked"
  • onlinebooleanseen in the last 3 minutes
  • lastSeenAtstring | null
  • linkedAtstring | null
  • nowPlayingobject | null
  • nowPlaying.itemCountinteger
  • nowPlaying.firstItemstring | null
  • nowPlaying.transitionstring | null
  • nowPlaying.slideSecondsinteger | null
  • nowPlaying.pushedAtstring | null

Playlist

  • playlistIdstring
  • namestring
  • itemsobject[]
  • items[].s3Keystring
  • items[].titlestring | null
  • items[].mediaType"image" | "video" | "audio" | null
  • shuffleEnabledboolean
  • backgroundMusicS3Keystring | null
  • scheduleobject | null{ rules: [{ days, startHour, endHour, … }] } — see the web app
  • transition"none" | "dissolve" | "slide" | "flip" | "zoom"
  • slideSecondsinteger | null2–3600
  • updatedAtstring

MediaItem

  • idstring
  • s3Keystringuse this in playlist items and pushes
  • thumbKeystring | null
  • titlestring
  • folderIdstring
  • mediaType"image" | "video" | "audio"
  • sizeinteger
  • createdAtstring

PushRequest

Either playlistId or items[]

  • playlistIdstring
  • itemsobject[]
  • items[].keystring · required
  • items[].type"image" | "video"
  • items[].titlestring
  • transition"none" | "dissolve" | "slide" | "flip" | "zoom"
  • slideSecondsinteger2–3600

Account

  • emailstring
  • namestring | null
  • userType"primary" | "secondary"
  • timezonestring | nullIANA zone used by playlist schedules
  • plan"pro" | "network" | "venue"
  • resourcesobject
  • resources.storageGBnumber
  • resources.storageUsedBytesinteger
  • resources.displaysLinkedinteger
  • resources.displaysLimitinteger
  • resources.usersLinkedinteger
  • resources.usersLimitinteger | null
  • keyobject
  • key.keyIdstring
  • key.namestring
  • key.scopes"read" | "write" | "push"[]

Folder

  • folderIdstring
  • namestring
  • parentFolderIdstring | null
  • isSharedboolean
  • isOwnerboolean

Track

  • idstring
  • titlestring
  • artiststring
  • moodstring
  • durationSecinteger
  • s3Keystringuse as backgroundMusicS3Key

PushResult

  • successboolean
  • pushedDisplaysstring[]
  • failedDisplaysobject[]
  • failedDisplays[].displayIdstring
  • failedDisplays[].reasonstring

SlideFields

Content for a template. Which fields apply depends on the template (GET /slides/templates).

  • titlestring
  • subtitlestring
  • kickerstringsmall label above the title
  • bodystringparagraph; newlines allowed
  • badgestringtext inside the round badge (promo)
  • itemsobject[]
  • items[].namestring · required
  • items[].pricestring
  • items[].descstring
  • items[].tagstring
  • logostringlibrary key (s3Key) of your logo
  • photostringlibrary key of a photo
  • accentstring#RRGGBB
  • font"Inter" | "Playfair Display" | "Bebas Neue" | "Lora" | "Montserrat"
  • namestringfile title in the library

SlideRequest

Either template + fields, or a complete Studio doc.

  • template"menu-board" | "price-list" | "promo" | "announcement" | "hours" | "welcome" | "event" | "portrait-menu"default "announcement"
  • fieldsSlideFields
  • docobjecta complete StudioDoc (as returned by /slides/propose)
  • namestring
  • playlistIdstringappend the slide to this playlist
  • screenIdsstring[]push the slide to these screens (needs the push scope)

SlideResult

  • s3Keystringthe library key — use it in playlists and pushes
  • titlestring
  • folderIdstring
  • sizeobject
  • size.winteger
  • size.hinteger
  • bytesinteger
  • readybooleantrue once the processed file is in the library (it always is within a minute)
  • playlistobject
  • pushPushResult

Generated from the OpenAPI document at build time. The live copy at https://api.ztalio.com/v1/openapi.json is the source of truth and the one to feed a code generator or an assistant.

Uploading a file.

Three calls, all with the write scope: start the upload, PUT the parts straight to storage, then complete it. ztalio upload does all three.

1 · INIT

POST /uploads

Send filename, contentType, sizeBytes and totalParts (parts of up to 100 MB), plus an optional folderId. You get an uploadId and the file’s future s3Key.

2 · PUT PARTS

POST /uploads/{uploadId}/part-urls

Ask for presigned URLs for the part numbers you need, then HTTP PUT each part’s bytes to its URL. Keep the ETag header each PUT returns.

3 · COMPLETE

POST /uploads/{uploadId}/complete

Send the parts list of { partNumber, etag }. The file is optimised (videos to H.264 1080p, portrait gets blurred pillars) and appears in the library within a minute.

# or let the CLI do it
ztalio upload ./menu.png --folder Promos
# about a minute later
ztalio media --folder Promos

A file that is not yet in GET /media is still being optimised; poll the library rather than the upload. DELETE /uploads/{uploadId} abandons an upload that will not complete.