Files and one-off tasks for agents

Operate a deployed Service's filesystem and run bounded commands through the API. This page is written for an AI agent. It extends the deployment protocol at https://app.tokay.io/llms.txt and assumes you hold a working credential and use the Relay node IDs GraphQL returns. Everything here is an escape hatch. The happy path needs none of it, so act on an explicit user request or a named readiness action, not on your own initiative.

Last updated

The file model

  • Every Service has two file scopes. SAVED is durable app owned storage. It survives deploys and restarts, mounts into live containers and one-off tasks, and is the only place durable data belongs. CURRENT is the filesystem of the live container right now. It is read only evidence and disappears when the instance is replaced.
  • service.fileRoots is the map. Each root is an absolute container path such as /tokay/data or /app/uploads. Every path you pass on this page must be an absolute path inside one of these roots. /tokay/data always exists.
  • SAVED roots come from declared storage paths. Tokay detects natural write directories such as ./uploads during analysis and declares them for you (author TOKAY, state STICKY). You can declare more.
  • Listings validate paths immediately. Ticket minting validates nothing. A wrong path in a download or upload ticket fails at redemption, not at mint, so verify paths against a listing first.
  • A one-off task is the only exec path. There is no SSH. runServiceTask runs your command in the Service's current image with its full runtime environment and SAVED storage mounted, and records the output durably.

What to call

To Use
See which directories are durable service.fileRoots
List files in a scope service.files(scope:, path:)
Download files createServiceFileDownloadTicket, then GET the URL
Upload into durable storage createServiceFileUploadTicket, then PUT the bytes
Save a live file before it disappears copyCurrentFileToSaved
Run a bounded command runServiceTask, then poll releaseExecution
Inspect declared durable directories service.serviceStoragePaths
Make another directory durable addServiceStoragePath

Find the roots, then list inside them

query Files($id: ID!) {
  service(id: $id) {
    fileRoots { path scope durability taskVisible writable survivesDeploy }
    files(scope: SAVED, path: "/app/uploads", first: 200) {
      entries { name path type sizeBytes updatedAt }
      currentInstanceRef
    }
  }
}

Query fileRoots first and list inside a root. Listing path: "/" fails with "Path is not inside Saved service files" unless / is itself a root, which is true only for CURRENT. Entry type is FILE, DIRECTORY, or SYMLINK. A CURRENT listing needs a running container and returns currentInstanceRef. Save that ref when you plan to rescue.

Download files

mutation DownloadTicket($service: ID!, $paths: [String!]!) {
  createServiceFileDownloadTicket(input: {
    service: $service,
    serviceFileScope: SAVED,
    paths: $paths,
    serviceFileArchiveFormat: RAW
  }) {
    serviceFileDownloadTicket { downloadUrl expiresAt }
  }
}

Then GET {API_ORIGIN}{downloadUrl} with no Authorization header. The ticket is the authorization. Tickets expire quickly and work once. Use RAW for one file and TAR_GZ for several paths or a directory. Minting succeeds even for paths outside SAVED. The GET then returns HTTP 400 with {"error": ..., "code": "SERVICE_FILE_PATH_NOT_SAVED"}. For a CURRENT download pass serviceFileScope: CURRENT plus expectedCurrentInstanceRef from a listing.

Upload into durable storage

mutation UploadTicket($service: ID!) {
  createServiceFileUploadTicket(input: {
    service: $service,
    destinationPath: "/app/uploads/report.csv",
    overwrite: true,
    expectedSizeBytes: "1024"
  }) {
    serviceFileUploadTicket { uploadUrl maxBytes expiresAt }
  }
}

Then PUT {API_ORIGIN}{uploadUrl} with the raw bytes as the body. Success returns {"ok": true, "bytesReceived": N}. expectedSizeBytes is a BigInt scalar, so send it as a string in JSON variables. destinationPath must be absolute inside a SAVED root. A wrong destination fails at PUT with code SERVICE_FILE_DESTINATION_NOT_SAVED. The running app sees the file immediately, with no restart. Respect the returned maxBytes.

Rescue a live file

If the app wrote something important outside durable storage, copy it into SAVED before the instance changes.

mutation Rescue($service: ID!, $ref: String!) {
  copyCurrentFileToSaved(input: {
    service: $service,
    sourcePath: "/app/report.json",
    destinationPath: "/app/uploads/report.json",
    expectedCurrentInstanceRef: $ref,
    overwrite: true
  }) {
    currentFileCopy { id currentFileCopyStatus errorCode errorMessage }
  }
}

The copy is asynchronous. Poll currentFileCopy(id:) until currentFileCopyStatus reaches SUCCEEDED or FAILED (PENDING and RUNNING come first). expectedCurrentInstanceRef must come from a CURRENT listing. If the container was replaced between your read and the copy, the guard fails the copy instead of copying a different instance's file. Re-list and retry. After a rescue, declare the source directory durable so future writes survive on their own.

Run a one-off task

mutation Task($service: ID!, $command: [String!]!) {
  runServiceTask(input: { service: $service, command: $command }) {
    releaseExecution { id }
  }
}

command is argv, not a shell string. Wrap pipes and chains in ["sh", "-c", "..."]. The working directory defaults to the service root, so relative paths like uploads resolve against /app. Pass workingDirectory to change it. Poll the returned row:

query TaskResult($id: ID!) {
  releaseExecution(id: $id) { releaseExecutionState exitCode logTail }
}

releaseExecutionState reaches SUCCEEDED or FAILED. Simple commands finish in a few seconds. The task runs the Service's current image with its full runtime environment (secrets, database URLs) and SAVED storage mounted, and the run is attributed to your identity and credential. The command must exit. A server or a watch loop belongs in a Service.

Tasks are the right tool for data fixes. The managed database query endpoint is read only, but a task's framework client holds real credentials from the environment. Schema changes should still ship as migrations with a deploy, which adds rehearsal, snapshots, and confirmation for destructive changes.

Durable directories

query StoragePaths($id: ID!) {
  service(id: $id) {
    serviceStoragePaths(first: 20) {
      nodes { id storagePath serviceStoragePathState serviceStoragePathAuthor }
    }
  }
}

storagePath is relative to the service root, so uploads mounts at /app/uploads. States are PENDING (recorded, mounts after clean build evidence or user confirmation), STICKY (mounted and durable), and DETACHED (intentionally unmounted, row kept as a tombstone, data preserved). Check the existing list before declaring anything. Tokay usually detected the app's write directories already, so add one only when the user asks or the app demonstrably writes somewhere undeclared, with addServiceStoragePath(input: { service: $service, storagePath: "data" }). Detach and delete only when the user asks. detachServiceStoragePath stops mounting but keeps the stored data, reattachServiceStoragePath reverses it, and destroying data is the separate deleteServiceStoragePathData. New code should skip all of this and write under TOKAY_DATA_DIR.

Error shapes

You see It means Do this
GraphQL error "Path is not inside Saved service files" Listing or mutation path is outside the declared roots Query fileRoots, use an absolute path inside a root
HTTP 400 {"code": "SERVICE_FILE_PATH_NOT_SAVED"} on ticket GET The download ticket was minted with a bad path Re-list, mint a new ticket with the listed path
HTTP 400 {"code": "SERVICE_FILE_DESTINATION_NOT_SAVED"} on PUT Upload destination is outside SAVED Target an absolute path inside a SAVED root
currentFileCopyStatus: FAILED with errorCode Rescue failed, often a replaced instance or bad destination Read errorCode and errorMessage, re-list CURRENT, retry with the fresh currentInstanceRef
GraphQL error "Not authorized for ACTION on TYPE id" Your credential lacks that permission on that resource Report the missing grant to the user instead of retrying