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.
SAVEDis 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.CURRENTis the filesystem of the live container right now. It is read only evidence and disappears when the instance is replaced. service.fileRootsis the map. Each root is an absolute container path such as/tokay/dataor/app/uploads. Every path you pass on this page must be an absolute path inside one of these roots./tokay/dataalways exists.- SAVED roots come from declared storage paths. Tokay detects natural write directories such as
./uploadsduring analysis and declares them for you (authorTOKAY, stateSTICKY). 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.
runServiceTaskruns 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 |