Skip to main content
A dataset that people have annotated usually has to reach a training pipeline that lives in a cloud project. Publish copies one dataset out of the Lab into a Cloud Storage bucket: the recordings, and a manifest that names them. Nothing on the datasets volume changes, and the dataset stays annotatable afterwards. A decision dataset is the exception. It holds no recordings and no transcript manifest, so it is not published to a bucket: download its gold set instead (Decision gold sets). There are two kinds of destination, and the difference is only where the credential lives.

A bucket Levers operates — nothing to set up

Pick it and publish. No key, no secret, no first-time setup: the dashboard holds the credential and signs one short-lived upload URL per object, and the pipeline container uploads straight to Cloud Storage with those. A platform admin configures which buckets appear (LEVERS9_PUBLISH_DESTINATIONS on the dashboard). This is deliberate, not just convenient. A workspace secret is readable by whoever owns that workspace — they can attach it to a job of their own and print it — so a key for a bucket holding every tenant’s training data must not live there. What the container gets instead is a ticket scoped to one dataset’s folder: it cannot write to another dataset, cannot climb out of its folder, and stops working when the run is over.
A shared bucket is one set of folders for every workspace, so a folder belongs to the first workspace that publishes to it. Publishing again from that workspace works as before. Another workspace that picks the same folder name gets a 409 saying the folder belongs to another workspace; pick another folder name with --folder (or in the Publish dialog). Folders published before owners were recorded went to the workspace that published them first, and a platform admin can hand a folder to another workspace from Admin → unowned. The tickets are signed with the dashboard’s LEVERS9_AUTH_SECRET. A dashboard without one (a local one with sign-in off) offers no shared buckets at all, and says so if you ask for one.

Your own bucket — a key you control

For a client’s project, or anywhere Levers does not operate the bucket, the credential is a service-account key stored as a workspace secret. It is read inside the pipeline container that does the upload, never by the dashboard, and never written to the dataset’s config — the config records only which secret to read. The rest of this page is that path.

1. A service account that may write to the bucket

Give it object access to that one bucket, nothing else:
objectAdmin rather than objectCreator because re-publishing overwrites the manifest. If nothing may ever be overwritten, use objectCreator and publish with a fresh folder name each time.

2. The key as a workspace secret

The whole key file is the secret’s value, under the name the pipeline reads, SERVICE_ACCOUNT_JSON:
Or paste it on the dashboard’s Secrets page. Either way, delete the local file afterwards.
Publishing to more than one project? Store each key under its own name (STAGING_SA, ACME_PROD_SA) and bind it per dataset with secret_map, exactly as sources and providers do. The pipeline still asks for SERVICE_ACCOUNT_JSON; the map says which secret answers.

3. Publish

From the dataset page, Publish asks for the bucket, a folder name (the dataset’s name by default), which secret holds the key, and whether to send only the rows a human has validated. From a terminal, with the CLI:
It prints each stage as the run goes, then the manifest’s path. Or run the pipeline directly from its own folder, which is the same code:
Or against the dashboard API:

More than one bucket: dev, staging, prod

The layout below is the same in every bucket, so a second destination is setup only — no different commands. Repeat steps 1 and 2 against that project and store its key under its own secret name:
A project with no datasets bucket yet gets one configured the way the others are (same region, uniform bucket-level access, public access prevention) before you point a publish at it:
In the dashboard, the Publish dialog lists every bucket this workspace has published to before (only this workspace’s: other workspaces’ buckets and datasets aren’t listed). Picking one fills in the bucket and the secret that opened it, which is the pairing that is easy to get wrong by hand; a bucket whose secret has since been deleted is marked rather than silently offered. The list comes from the runs’ own receipts, so a bucket appears once someone has published to it and never needs registering.
Nothing distinguishes a prod bucket from a dev one except its name, and --only-validated is opt-in everywhere. Publishing without it sends model drafts too — fine for staging, worth thinking about for a corpus other people train on.

What lands in the bucket

Those two prefixes are the staging bucket’s pipeline entry points (an Eventarc trigger runs the audio-pipeline workflow for objects under them), so the manifest is uploaded last: by the time anything downstream reads a line, the audio that line names is already there. The dev and prod buckets have no such trigger today, but the layout is deliberately identical so a dataset moves between them unchanged. The manifest keeps the column names the structured folder already uses (FileName, SegmentID, SegmentStart, SegmentEnd, Speaker, GroundTruthText, ProcessedText, …), so a reader written for the datasets already in that folder reads this one too. Four columns are ours and sit at the end: Split, Status, CallID and Provider — a reader that selects by name ignores them, and a person opening the CSV can see which rows a human signed off. For a speech dataset, one recording is uploaded once even though many rows are chunks of it; each row’s SegmentStart and SegmentEnd cut it. GroundTruthText is the transcript a person confirmed, falling back to the model’s draft when there is none — so publishing early gives drafts, and publishing again after annotation replaces them. If the dataset’s policy is Mask, the manifest carries the masked text: [NAME], [PHONE] and the rest in place of what the scan found, or of what a reviewer settled where a row was reviewed. The recordings go as recorded, because Mask covers text and not audio. A manifest is read by a training pipeline, so a publish doesn’t write a line it can’t vouch for: if a row’s text changed since the last scan, the run stops before it uploads anything and asks you to scan again. Publish isn’t the only way out. For a file to train on with made-up names and numbers in place of the real ones, and a report that says how well the scan was checked, see Export for training. From a terminal that’s levers9 dataset pii export.

Interrupted uploads

Uploads are resumable, which matters at corpus scale: a dataset is thousands of files and can be tens of gigabytes. Each file goes up as a GCS resumable session, and if a transfer breaks the next attempt asks the bucket how many bytes it actually holds and continues from there rather than starting the file again. Session URIs are written beside the dataset before the first byte, so a container that is killed — evicted, restarted, cancelled — resumes mid-file. If a run fails, run it again: it picks up where it stopped.

Publishing again

Publishing is safe to repeat. Audio already in the bucket is left alone (the run reports how many were skipped) and the manifest is rewritten, which is what you want after more rows are validated. Pass overwrite to send the audio again as well. Each run appends a receipt to the dataset’s meta.json — bucket, folder, counts, and when — so the dataset page can show where its rows went.

When it fails

The bucket’s own README

Each dataset bucket carries a README.md describing this layout for people who open the bucket rather than the dashboard. The canonical copy is lab/ingest/BUCKET_LAYOUT.md in the repo — edit it there and upload, so the buckets and the docs cannot drift:
Next: Annotation to get rows validated first, or Insights to check what you are about to send.