Logopicto

Give Your AI Agent Your Sessions

Let a coding agent list your recorded sessions, read their timelines, and look at rendered video or frames while it hunts for a bug.

Agent access is a **Scale and Enterprise** feature. On any other plan every command and MCP tool answers `plan_required` (exit code `5`) with a link to your organization's billing settings — see [pricing](https://picto.dev/pricing).

A coding agent fixing a bug report is usually guessing at what the user did. dart run picto:sessions lets it look instead: list the sessions that hit an error or a rage tap, read exactly what happened and when, and pull a short video or a handful of still frames from around the moment it went wrong.

The command ships inside the picto package, so any project that depends on picto already has it. It reads only what your account can read on the dashboard — nothing more — and it never writes anything.

Sign in#

dart run picto:sessions login

It asks for your picto account's email and password (the password is not echoed), signs in, and saves the session to ~/.config/picto/credentials.json, readable only by you. Pass --email to skip the first prompt.

That one sign-in is what your agent uses from then on. The saved session is renewed automatically as it is used, so as long as something runs a picto:sessions command at least once a week, it never needs you again. If it has lapsed — or you signed out everywhere — every command exits with code 3 and the message run dart run picto:sessions login, which is the agent's cue to ask you.

dart run picto:sessions logout

ends the session on the server too, not just on your machine.

Signing in is per server. To point the CLI at a different server, such as a local development backend, pass --endpoint (or set PICTO_ENDPOINT) on login and on every later command.

For CI, `--token` or the `PICTO_TOKEN` environment variable overrides the saved session with a bearer token you supply. Nothing is saved, and nothing is renewed.

The commands#

CommandWhat it does
list Recent sessions, newest first. Filters: --has-errors , --has-rage-taps , --platform ios , --client-app <id> , --limit 20 . Pages with --before <nextBefore> .
show <id> One session: its segments, metadata, and every exception, tap cluster and slow frame with its time in the session.
timeline <id> Every event in the session — screens, taps, network calls, errors, rage taps, your own tags — in order, as structured data. No pixels, so it is fast and cheap.
video <id> Renders an .mp4 ( -o out.mp4 , --fps 10 , --start 1:00 --end 1:30 ) with a .manifest.json and .captions.vtt beside it.
frames <id> Renders PNG stills into a directory ( -o dir ): one every --every 1s , plus one at each tap, screen change and error.
download <id> The raw recording bytes of one segment ( --sequence N when the session has more than one).

video, frames and timeline render on picto's servers and wait for the result; the first render of a session takes a little while, an identical one after that is instant. Frames and video are downscaled to 720px wide by default (--max-width), which is plenty to read a screen and keeps the images cheap for a model to look at.

Output an agent can read#

On a terminal the commands print tables and summaries. When stdout is not a terminal — which is how an agent's shell tool runs them — they print JSON, and --json forces it anywhere. Failures are JSON too, with an exit code an agent can branch on:

Exit codeMeaning
0Success
1Something failed (the message says what)
2The command was used wrongly
3Not signed in, or the session expired — sign in again
4No such session, or not one this account can see
5 The session's organization is not on the Scale or Enterprise plan — the JSON error has code: "plan_required" and an upgradeUrl . Signing in again will not help; upgrading will

Connect it as an MCP server#

If your agent speaks MCP, it can call the same commands as tools instead of running them in a shell. dart run picto:mcp is a small MCP server (over stdio) that ships in the same picto package and uses the same saved sign-in.

Sign in once in a terminal first — the MCP server never asks for a password:

dart run picto:sessions login

Claude Code. From your app's project directory (the one whose pubspec.yaml depends on picto):

claude mcp add picto -- dart run picto:mcp

Other clients. Most read an mcp.json (or similar) like this. dart run picto:mcp has to start inside a project that depends on picto, so point cwd at your app:

{
  "mcpServers": {
    "picto": {
      "command": "dart",
      "args": ["run", "picto:mcp"],
      "cwd": "/path/to/your/app"
    }
  }
}

If your client has no cwd setting, use "command": "sh" with "args": ["-c", "cd /path/to/your/app && dart run picto:mcp"].

The tools mirror the commands above:

ToolSame asNotes
list_sessions list limit , before , has_errors , has_rage_taps , platform , client_app_id
get_sessionshow
get_timeline timeline start, end, keep_idle
render_video video Returns the paths of the .mp4, manifest and captions, plus the manifest itself
render_frames frames Returns the frame directory and the manifest. include_images: 4 also returns four of the frames, evenly spread and downscaled ( image_max_width , 480 by default), so the model can look at them directly
download_recording download sequence when the session has more than one segment

Renders and downloads go to a picto-mcp folder in your system's temp directory unless a call passes output; every result gives absolute paths. A render's result starts with its idle note and any image warnings (see the next two sections), ahead of the JSON.

When nobody is signed in, or the saved session has lapsed, every tool returns an error saying to run dart run picto:sessions login — run it in a terminal, then retry. To use a different server, start the MCP server with --endpoint <url> (or set PICTO_ENDPOINT), matching the server you signed in to. PICTO_TOKEN works here the same way it does for the CLI.

When the organization is not on the Scale or Enterprise plan, every tool returns an error that says so, with the upgrade link, and tells the model this is a plan limit rather than a sign-in problem.

Idle stretches are cut by default#

Most sessions have long stretches where nothing happens — someone reading, or the phone face down on a table. A video of those is long, slow to render, and expensive for a model to watch, so video and frames skip idle stretches by default. This is the same rule the dashboard's "Skip the idle stretches" uses: any quiet gap longer than 5 seconds is cut down to its first 5 seconds, so the moment right after an action always stays in.

Cutting time means the output's clock is no longer the session's clock, and every render says so plainly:

  • The command prints a note (on stderr, and as idleNote in the JSON result), for example: 2 idle stretches totalling 1m 12s were cut; output time != session time. Use segments[] to map, or re-render with idle=keep.
  • manifest.json lists each cut under idle.skipped with its session start, end, length and reason (idle, or before_first_paint — see below); idle.savedMs is the total of all of them, and segments maps every stretch of output time back to session time. Every event carries both its sessionMs and its outputMs — null when it fell inside a cut.
  • captions.vtt has a cue at every cut ("⏩ skipped 42s idle (session 1:03→1:45)") and one at each notable event, so a model watching the video sees the cuts too.

To keep everything, pass --keep-idle. Use it together with --start/--end on a long session: a render that would produce a very long video, or too many frames, fails with a message saying to narrow the window. The opposite case fails too — if the whole window you asked for was idle, there is nothing left to show, and the message tells you to re-run with --keep-idle.

Before the app's first paint#

A session starts recording a moment before your app draws anything: the session start and first screen events land in the first few milliseconds, but the first frame with content can come a fraction of a second (or, on a slow cold start, a few seconds) later. Frames from that stretch would be blank, so every render cuts it — from the start of the window to the first frame the replay has something to draw — as a leading cut with "reason": "before_first_paint". It is counted in savedMs, named in the note (… and 636ms before the app's first paint), and gets its own caption cue (⏩ skipped 636ms before the app's first paint); events inside it, like session_start, have outputMs: null, and get no still in a frames render — there is nothing on screen yet to show.

--keep-idle keeps idle time but still cuts this stretch: blank frames are never what you were asking to keep. The note says so (Idle stretches were kept (idle=keep), but the 636ms before the app's first paint was cut …: output time = session time - 636ms.). A window that starts after the first paint has no such cut.

An example agent workflow#

Say a user reports that checkout "sometimes does nothing". An agent with a shell can work it through like this:

# 1. Find sessions where someone hammered a button that did not respond.
dart run picto:sessions list --has-rage-taps --limit 10

# 2. Read the structured timeline of one of them, and find the rage tap.
dart run picto:sessions timeline 3f9c2a7e

# 3. The rage tap is at session time 1:42 — look at the screen around it.
dart run picto:sessions frames 3f9c2a7e --start 1:30 --end 1:50 --every 2s -o frames/

# 4. Watch the same window as a video, with taps drawn on.
dart run picto:sessions video 3f9c2a7e --start 1:30 --end 1:50 -o checkout.mp4

The timeline in step 2 is usually enough on its own: it shows the screen the user was on, the taps leading up to the rage tap, and — if the app captures network requests — whether the call the button makes ever came back. The frames and video confirm what the screen looked like at the time.

When the agent quotes a time back to you, it should be a session time (sessionMs in the manifest), which is what the dashboard's replay timeline shows — not a position in the cut-down video.

Images in the render#

Renders draw your app's bundled images the same way the dashboard's replay does: from the assets you uploaded with dart run picto:upload_assets. An image that cannot be fetched — most often one that was never uploaded — does not fail the render. It is left out of the frames (a blank space, or a small gray box), and the render says so: manifest.json lists it under warnings, each with the image's reference (such as asset:assets/images/logo.png) and a message, and video and frames print a warning: line for it on stderr and include the list as warnings in their JSON result. When an agent sees an empty space where it expected something, it should check warnings before deciding the app drew nothing there.

What the agent can see#

Exactly what your account can see on the dashboard: sessions from organizations you own or collaborate on, as long as that organization is on the Scale or Enterprise plan. list leaves out sessions from organizations on other plans, and answers plan_required only when none of yours qualifies; opening one of those sessions directly answers plan_required too. A session from anywhere else answers "not found", the same as one that does not exist. The agent never sees your API keys, and the rendered files expire from picto's servers after 24 hours.