npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@floyoai/sdk

v0.5.0

Published

Official Node.js SDK for the Floyo API

Readme

@floyoai/sdk

Official Node.js SDK for the Floyo API.

  • Uploads files and whole folders straight from your machine to storage in parallel parts. File bytes never pass through the Floyo API.
  • Browses your storage: list a folder page by page, walk a whole folder tree, retrieve one file's metadata, or delete a file or folder.
  • Downloads files and whole folders (recursively) straight from storage, in parallel byte ranges.
  • Browses public and team workflows, retrieves a workflow's full details, including its ComfyUI prompt, and fills in the inputs the workflow declares.
  • Retries failed parts, resumes interrupted transfers, and reports live progress, speed and ETA.
  • Written in TypeScript, ESM-only, with no runtime dependencies.

Requires Node.js 22 or newer.

Install

npm install @floyoai/sdk

Quick start

import { Floyo } from '@floyoai/sdk'

const floyo = new Floyo('<your-api-key>')

const result = await floyo.files.upload(
  ['./renders/a.png', './renders/b.png'],
  {
    destination: { path: '/inputs/renders' },
    onProgress: (p) =>
      console.log(`${p.percent.toFixed(1)}% at ${p.mbps.toFixed(1)} Mbps`),
  },
)

for (const file of result.files) {
  if (file.status === 'completed')
    console.log(file.name, '→', file.file.inputPath)
  else console.log(file.name, file.status)
}

Client

const floyo = new Floyo(apiKey, options?)

apiKey is required. The SDK never reads it from environment variables or anywhere else: load it from wherever your app keeps secrets and pass it to every client you create. A missing or blank key throws a FloyoError with code missing_api_key.

| Option | Default | Description | | ------------ | ------- | -------------------------------------------------------------------- | | timeoutMs | 60000 | Timeout for each API request. | | maxRetries | 3 | Retries for API requests that fail with a network error, 429 or 5xx. |

The client always talks to https://api.floyo.ai.

floyo.files.upload(input, options?)

input is a file or folder path, an object { path, name?, contentType? }, or an array of either. If you leave contentType out, it is guessed from the file extension.

Folders are uploaded recursively and keep their name: with destination: { path: '/inputs/renders' }, the local folder ./photos becomes /inputs/renders/photos/…, subfolders included (name renames the folder). Missing remote folders are created. Hidden entries (names starting with .) and symlinks are skipped, and empty folders are not created. If the destination already has a folder with the same name, the files are added to it; folder inputs with the same name also share one folder.

await floyo.files.upload(['./photos', './cover.png'], {
  destination: { path: '/inputs/renders' },
})

The call returns an UploadTask right away. Await it to get one result per file, in input order with folders expanded in place. input tells which input a file came from:

interface UploadResult {
  destination: UploadedFolder | null // the destination folder; null when no file was uploaded
  folders: UploadedChildFolder[] // direct child folders made by folder inputs, in input order
  files: FileUploadResult[] // status: 'completed' | 'failed' | 'skipped' | 'canceled'; path, name, input
  completed: number
  failed: number
  skipped: number
  canceled: number
}

interface UploadedFolder {
  type: 'folder'
  id: string // 'file_…'
  name: string
  path: string // '/inputs/footage/take0008'
  inputPath: string // '#inputs/footage/take0008', usable as a workflow input
  created: boolean // true if this upload created it, false if it already existed
}

interface UploadedChildFolder extends UploadedFolder {
  inputs: number[] // the inputs uploaded into it (several when names match)
}

A file that fails does not affect the others. Its result has status: 'failed' and an error.

destination and folders give you a reference to every folder the upload wrote into directly, whether it created them or not:

const { destination, folders } = await floyo.files.upload(
  ['./clip.mp4', './photos', './audio'],
  { destination: { path: '/inputs/footage/take0008' } },
)

destination?.id // take0008
folders.map((folder) => [folder.name, folder.id]) // [['photos', 'file_…'], ['audio', 'file_…']]
await floyo.files.download(folders[0]!) // folder references work with list, walk, download and delete

Before the first file starts, the SDK makes one call that resolves the destination and creates the missing folders: the destination itself and one direct child per folder input. This call happens only when at least one file is about to be uploaded. The folders are kept even if their files later fail or the upload is canceled. When that call fails because the destination cannot be used (InvalidRequestError with code destination_not_found or invalid_destination), the task rejects and nothing is uploaded. When only one child folder cannot be created, because a file with that name exists (folder_conflict) or the name is not allowed (invalid_folder_name), its files fail with that code and the rest continue. created describes this call only, so a resumed upload reports created: false.

Options

| Option | Default | Description | | -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | destination | /inputs/sdk/uploads | { folderId } or { path, createParents? }. Missing folders are created unless createParents: false. | | onConflict | 'rename' | What to do when a file with the same name exists: 'rename' or 'error'. | | concurrency | 8 | Parts uploaded in parallel across all files. | | maxConcurrentFiles | 4 | Files uploaded at the same time. Also capped by your account's upload quota. | | maxAttempts | 5 | Attempts per part before the file fails. | | partSize | chosen by the API | Preferred part size in bytes. The API may adjust it. | | resume | true | true (records in ~/.floyo/uploads), false, { dir } or { store }. See Resuming uploads. | | onInvalidFile | 'error' | 'error' rejects the whole task before any upload starts. 'skip' uploads the valid files only. Files found inside folders that cannot be uploaded (empty, unreadable, type not allowed) are always skipped. | | onProgress | | Called with an UploadProgress snapshot every progressIntervalMs. | | progressIntervalMs | 250 | Progress reporting interval. | | signal | | AbortSignal that stops the upload and keeps unfinished files resumable. |

Controlling a running upload

const task = floyo.files.upload(paths)

task.pause() // interrupts active parts; they restart from their first byte on resume
task.resume()
task.paused // boolean
task.progress() // current UploadProgress snapshot
await task.cancel() // stops and deletes unfinished remote uploads and resume records

const result = await task

UploadTask implements Promise<UploadResult>, so .then, .catch, .finally and Promise.all all work.

Use the signal to stop an upload so you can continue it later. Use cancel() to drop the upload entirely:

| Action | Unfinished files | Remote upload | Resume record | | ---------------- | --------------------- | ------------- | ------------- | | signal aborted | canceled, resumable | kept | kept | | task.cancel() | canceled | deleted | deleted |

Progress

UploadProgress covers the whole task. files holds the same fields for each file:

| Field | Description | | ----------------------------- | ------------------------------------------------------------------------ | | totalBytes, uploadedBytes | Bytes in finished parts plus bytes sent by active parts. | | percent | 0–100. | | bytesPerSecond, mbps | Recent speed (2-second window). mbps is decimal megabits per second. | | etaSeconds | Estimated seconds left, or null while the speed is unknown. | | paused | Whether the task is paused. | | retries, failedAttempts | Part attempts that were retried, and part attempts that failed. | | fileCounts | Number of files per status, plus total. | | files[].activeParts | Parts in flight: partNumber, uploadedBytes, totalBytes, attempt. |

Events

task.on('progress', (progress) => {})
task.on('destination', ({ destination, folders }) => {}) // once, before the first file starts
task.on('file:start', ({ path, name, uploadId, resumed }) => {})
task.on('file:complete', ({ path, name, file }) => {})
task.on('file:fail', ({ path, name, error }) => {})
task.on(
  'part:retry',
  ({ path, name, partNumber, attempt, delayMs, error }) => {},
)

on, once and off are typed. If a listener throws, the upload keeps running and the error is rethrown asynchronously.

floyo.files.list(path?, options?)

Lists the files and folders directly inside one folder. path defaults to / (your whole storage: inputs, outputs, …) and can also be a folder item from an earlier list() or walk(). Paths are normalized, so inputs, /inputs/ and //inputs are the same folder.

Await the call to get one page, or iterate it with for await to get every item. Pages are fetched one at a time, only when needed, and every page reuses the same path, search, sort and filters:

// One page, e.g. for a paginated UI
const page = await floyo.files.list('/inputs', { sort: 'name', pageSize: 50 })
page.items // FloyoItem[]
page.hasMore // boolean
const next = await page.nextPage() // FilePage | null

// Every item in the folder
for await (const item of floyo.files.list('/outputs', { search: 'render' })) {
  console.log(item.type, item.path, item.sizeBytes)
}

// Page by page, e.g. to process a large folder in batches
for await (const page of floyo.files.list('/inputs/renders').pages()) {
  await handle(page.items)
}

// Drill down: pass a folder item straight back in
for await (const item of floyo.files.list()) {
  if (item.type === 'folder') console.log((await floyo.files.list(item)).items)
}

On Node 22, await Array.fromAsync(floyo.files.list(path)) collects every item into an array (it needs "lib": ["esnext"] or newer in your tsconfig).

Each item is a file or a folder. Use type to tell them apart:

type FloyoItem = {
  id: string // 'file_…' (folders use the same prefix)
  name: string
  path: string // '/inputs/photos/cat.png'
  sizeBytes: number // for folders: total size of everything inside, at any depth
  inputPath: string | null // '#inputs/photos/cat.png', usable as a workflow input
  createdAt: string // ISO 8601
  updatedAt: string
} & ({ type: 'file'; mimeType: string | null } | { type: 'folder' })

Options

| Option | Default | Description | | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------- | | search | | Only items whose name contains this text, case-insensitive, 3–128 characters. Searches this folder only. | | sort | 'createdAt' | 'createdAt', 'name' or 'size'. Ties are broken by id, so the order is stable across pages. | | order | see below | 'asc' or 'desc'. Defaults to 'asc' for name and 'desc' (newest or largest first) otherwise. | | pageSize | 100 | Items per request, 10–100. | | createdAt | | { gte?, lte? } with Date objects or date strings. Inclusive. | | sizeBytes | | { gte?, lte? } in bytes. Inclusive. Folders are compared by their total size. | | cursor | | page.cursor from an earlier call. Continues that listing; the path and every other option must be the same. | | signal | | AbortSignal that stops the listing. |

Invalid options throw a FloyoError with code invalid_option before any request is sent. A folder that does not exist rejects with NotFoundError (code folder_not_found), and a cursor used with different options rejects with InvalidRequestError (code invalid_cursor). Listings that hit the API rate limit wait and continue instead of failing.

floyo.files.retrieve(file, options?)

Retrieves one file's metadata. file is a file id (file_ plus 16 base58 characters) or any object with that id: an item from list() or walk(), or file from a completed upload.

const file = await floyo.files.retrieve('file_NS8KPRtb8LJjatAA')

file.name // 'cat.png'
file.mimeType // 'image/png', or null when unknown
file.sizeBytes
file.createdAt // ISO 8601
file.inputPath // '#inputs/photos/cat.png', or null when it cannot be a workflow input

const shared = await floyo.files.retrieve(file, {
  expand: 'presignedUrl',
  presignedUrlExpiresIn: 600,
})
shared.presignedUrl // public URL, valid for 600 seconds

A presigned URL carries its own access: anyone who has it can read the file until it expires, with no API key. It is included only when you set expand: 'presignedUrl'.

Options

| Option | Default | Description | | ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- | | expand | | 'presignedUrl' adds a time-limited public URL. | | presignedUrlExpiresIn | 300 | Lifetime of that URL in seconds, from 30 to 86400 (24 hours). The API may enforce a lower maximum. Requires expand: 'presignedUrl'. | | signal | | AbortSignal that stops the request. |

Invalid options throw a FloyoError with code invalid_option before any request is sent. A file that does not exist, or a folder id, rejects with NotFoundError (code file_not_found).

floyo.files.presign(files, options?)

Creates a time-limited public URL for each file, for services that fetch files by URL, such as a remote upload into Frame.io, Dropbox or S3. files is one file or an array of files: ids, or any objects with an id (items from list() or walk()).

const files = []
for await (const item of floyo.files.walk('/outputs/renders', {
  type: 'file',
})) {
  files.push(item)
}

const signedAt = Date.now()
const result = await floyo.files.presign(files, { expiresIn: 3600 })
const validUntil = signedAt + result.expiresIn * 1000

for (const file of result.files) {
  file.path // '/outputs/renders/shot-01.mp4'
  file.url // public URL; treat it as a secret
}
for (const failure of result.failed) {
  failure.id
  failure.code // 'file_not_found' or 'is_folder'
}

Every URL lives expiresIn seconds from when its request returned. Lists longer than 100 files are sent in requests of 100, so for large trees sign each batch just before you hand its URLs out. failed lists unknown ids, deleted files and folders; they never reject the call. The demo's frameio-remote-upload recipe copies a whole folder tree to Frame.io this way.

Options

| Option | Default | Description | | ----------- | ------- | --------------------------------------------------------------------------------------------------- | | expiresIn | 300 | Lifetime of every URL in seconds, from 30 to 86400 (24 hours). The API may enforce a lower maximum. | | signal | | AbortSignal that stops the remaining requests. |

Invalid ids and options reject with a FloyoError with code invalid_option before any request is sent.

floyo.files.delete(item, options?)

Deletes a file or folder permanently. Deleted items cannot be recovered. item is a file or folder id, or any object with that id (an item from list() or walk()).

const file = await floyo.files.delete('file_NS8KPRtb8LJjatAA')
file.type // 'file'
file.path // '/outputs/cat.png'

const folder = await floyo.files.delete(item, { recursive: true })
if (folder.type === 'folder') console.log(folder.deleteCount)

A file, or a folder with nothing inside it, is deleted as it is. A folder that still has files or folders needs recursive: true, which deletes the folder and everything inside it. recursive is ignored when the item is a file. Top-level folders such as inputs and outputs cannot be deleted.

Options

| Option | Default | Description | | ----------- | ------- | -------------------------------------------------------------------------------- | | recursive | false | Delete a folder and everything inside it. Required when the folder is not empty. | | signal | | AbortSignal that stops the request. |

Invalid options throw a FloyoError with code invalid_option before any request is sent. A missing file or folder rejects with NotFoundError (code item_not_found). A non-empty folder without recursive: true rejects with ConflictError (code recursive_delete_required) and nothing is deleted. A locked item rejects with PermissionDeniedError (code item_not_deletable), and a top-level folder with PermissionDeniedError (code top_level_folder_not_deletable).

floyo.files.walk(folder?, options?)

Yields every file and folder below a folder, at any depth, with parents before their contents. folder defaults to / and can be a path, a folder id or a folder item. Folder items and ids are exact even when two folders share a path.

for await (const item of floyo.files.walk('/inputs/renders', {
  type: 'file',
})) {
  console.log(item.relativePath, item.sizeBytes) // '2026/03/a.png', 182044
}

| Option | Description | | ---------- | ------------------------------------------------------------------------------------ | | search | Only items whose name contains this text, case-insensitive. Every level is searched. | | type | 'file' or 'folder'. | | maxDepth | Only items at most this many levels below the folder. 1 is the direct children. | | signal | AbortSignal that stops the walk. |

Each item has type, id, name, path, relativePath (below the walked folder), parentId and depth (direct children are 1). Files also have sizeBytes and mimeType. Items come in path order, up to 1000 per request; the filters are applied by the SDK, so the whole tree is still read. Stop early with break.

walk() uses the same endpoint as folder downloads: it needs an API key and SDK downloads enabled on the API. Otherwise it fails with code walk_unavailable.

Items from list() and walk() can be passed to download() as they are:

const renders = []
for await (const item of floyo.files.walk('/outputs', {
  type: 'file',
  search: '.png',
})) {
  renders.push(item)
}
await floyo.files.download(renders, { destination: './renders' })

floyo.files.download(input, options?)

input is one file or folder, or an array of them. Each one can be:

  • a file or folder id: a string like file_abcdefghijkmnopq (file_ plus 16 base58 characters), or { id };
  • a path such as /inputs/photos/cat.png or /outputs/run-1, or { path }. Any string that is not shaped like an id is a path. / downloads your whole storage;
  • an item from files.list() or files.walk(), downloaded by its id.
const result = await floyo.files.download(
  ['/inputs/photos', 'file_abcdefghijkmnopq'],
  { destination: './downloads' },
)

Folders are downloaded recursively and keep their name: with destination: './downloads', the folder /inputs/photos becomes ./downloads/photos/…, including empty subfolders. Files requested directly land in the destination itself.

The call returns a DownloadTask right away. Await it to get one entry per input and one per file:

interface DownloadResult {
  inputs: DownloadInputResult[] // status: 'resolved' (kind 'file' | 'folder', localPath, fileCount) | 'skipped' (code, reason)
  files: FileDownloadResult[] // status: 'completed' | 'failed' | 'canceled'; id, remotePath ('/inputs/…'), localPath, sizeBytes, input
  completed: number
  failed: number
  canceled: number
  skippedInputs: number
}

A file that fails does not affect the others. Its result has status: 'failed' and an error.

How it works

  1. The SDK asks the API what each input is. Folders are listed page by page (metadata only), so any number of files works even though the API grants at most 100 files per request.
  2. Every file gets a unique local path. Download tokens are requested just in time, in batches of up to 100 queued files, and replaced automatically when they expire.
  3. Small files (up to partSize) are fetched with one request. Larger files are fetched as parallel byte ranges pinned to the file's version (If-Match). If the remote file changes mid-download, the file restarts.
  4. Bytes go to <name>.floyo-part next to the target, then the file is flushed and renamed into place. A finished file is never half-written.

Local names

  • Remote names are made safe for your file system: /, \, control characters and names like .. are replaced with _, and on Windows reserved names and characters are handled too. Nothing is ever written outside destination, and the SDK does not follow symlinks out of it.
  • When two items would get the same local name (for example two remote files with the same name, or names that differ only by case on macOS and Windows), the later one is renamed: cat.png, cat (1).png, cat (2).png. Names are assigned in a stable order, so running the same download again gives the same paths.
  • If a local file already exists, that file fails with code file_exists and the rest continue. Set overwrite: true to replace existing files.
  • Requesting the same file or folder twice downloads it once; the duplicate input is skipped with code duplicate_input. Overlapping inputs (for example /inputs and /inputs/photos) are separate requests and produce separate copies.

Options

| Option | Default | Description | | -------------------- | -------------- | -------------------------------------------------------------------------------------------------------------- | | destination | current folder | Local folder that receives the downloads. Created if missing. | | overwrite | false | Replace local files that already exist. Without it, such files fail with file_exists. | | concurrency | 8 | Byte ranges downloaded in parallel across all files. | | maxConcurrentFiles | 8 | Files downloaded at the same time. | | maxAttempts | 5 | Attempts per byte range before the file fails. | | partSize | 16777216 | Byte range size (16 MiB). Files up to this size are downloaded with a single request. | | resume | true | true (records in ~/.floyo/downloads), false or { dir }. See Resuming downloads. | | onInvalidInput | 'error' | 'error' rejects the whole task before anything is downloaded. 'skip' downloads the valid inputs only. | | onProgress | | Called with a DownloadProgress snapshot every progressIntervalMs. | | progressIntervalMs | 250 | Progress reporting interval. | | signal | | AbortSignal that stops the download and keeps unfinished files resumable. |

Controlling a running download

DownloadTask works like UploadTask: pause(), resume(), paused, progress(), cancel(), and it implements Promise<DownloadResult>.

| Action | Unfinished files | .floyo-part file | Resume record | | ---------------- | --------------------- | ------------------ | ------------- | | signal aborted | canceled, resumable | kept | kept | | task.cancel() | canceled | deleted | deleted |

Progress and events

DownloadProgress has the same totals as UploadProgress (totalBytes, downloadedBytes, percent, bytesPerSecond, mbps, etaSeconds, paused, retries, failedAttempts, fileCounts), plus:

| Field | Description | | ------------- | --------------------------------------------------------------------------------------------------------- | | phase | 'listing' while folders are being listed (totals still growing), then 'downloading'. | | activeFiles | Files currently downloading, with their own bytes, speed and activeParts. Other files are only counted. |

task.on('progress', (progress) => {})
task.on('file:start', ({ id, remotePath, localPath, resumed }) => {})
task.on('file:complete', ({ id, remotePath, localPath, sizeBytes }) => {})
task.on('file:fail', ({ id, remotePath, localPath, error }) => {})
task.on(
  'part:retry',
  ({ remotePath, partNumber, attempt, delayMs, error }) => {},
)

Resuming downloads

For files larger than partSize, the SDK keeps a small record in ~/.floyo/downloads (directory mode 0700, records 0600) with the file id, its version (ETag), size and the byte ranges already flushed to disk. It never stores tokens or URLs.

Run the same download again (same destination) after an interruption and only the missing ranges are fetched, as long as the remote file has not changed. If it changed, the file starts over. Records are deleted once a file completes or fails permanently.

Resuming uploads

By default every file gets a small resume record in ~/.floyo/uploads. The directory is created with mode 0700 and each record with mode 0600. A record holds the file's identity (path, size and modification time) and its remote upload ID. It never stores part URLs.

If an upload is interrupted (signal aborted, process killed, network lost), run the same upload again with the same destination and conflict policy. The SDK asks the API which parts it already stored and sends only the missing ones. If the file changed on disk, it is uploaded from scratch. Records are deleted once a file completes or fails permanently.

To use another storage backend, pass a custom store with resume: { store } that implements ResumeStore (get, set, delete). FileResumeStore is exported if you only want a different directory.

floyo.workflows.browse(options?)

Lists the public workflows plus your team's private workflows. Await the call to get one page, or iterate it with for await to get every workflow. It works like files.list(): pages are fetched one at a time, only when needed, and every page reuses the same options.

// One page
const page = await floyo.workflows.browse({ sort: 'likes', pageSize: 10 })
page.workflows // WorkflowSummary[]
page.hasMore // boolean
const next = await page.nextPage() // WorkflowPage | null

// Every matching workflow
for await (const workflow of floyo.workflows.browse({
  search: 'image to video',
  tags: ['video', 'Text2Image'],
  modelType: 'opensource',
})) {
  console.log(workflow.id, workflow.name)
}

// Page by page
for await (const page of floyo.workflows.browse({ scope: 'team' }).pages()) {
  await handle(page.workflows)
}

Each workflow has these fields. The expanded fields are only present when you ask for them with expand:

interface WorkflowSummary {
  id: string // 'workflow_8Wn2kP4mQx7ZaBcD'
  name: string
  overview: string | null
  tags: string[]
  creator: { username: string; profileUrl: string | null } | null
  verified: boolean // ready to run on Floyo
  description?: string | null // expand: ['description'], sanitized HTML
  prompt?: WorkflowPrompt | null // expand: ['prompt'], ComfyUI API JSON
  stats?: { views: number; likes: number } | null // expand: ['stats']
  nodesData?: WorkflowNodePack[] | null // expand: ['nodesData']
}

Options

| Option | Default | Description | | ----------- | ------------ | ---------------------------------------------------------------------------------------------------------------- | | search | | Only workflows whose name, description, tags, nodes or models contain this text, 1–128 characters. | | sort | 'recent' | 'recent' (newest first), 'views' or 'likes' (most first). | | scope | 'all' | 'all' (public and your team's private workflows), 'team' (your team's private workflows only) or 'public'. | | status | 'verified' | 'verified' (ready to run on Floyo), 'unverified' or 'all'. | | modelType | 'all' | 'opensource' excludes workflows that use Floyo Partner Nodes; '3rd-party' returns only those. | | tags | | Up to 10 tags, as an array or a comma-separated string. Workflows with any of them are returned. | | pageSize | 25 | Workflows per request, 10–50. | | expand | | Extra fields to include: any of 'description', 'prompt', 'stats', 'nodesData'. | | cursor | | page.cursor from an earlier call. Continues that listing; every other option must be the same. | | signal | | AbortSignal that stops the listing. |

Invalid options throw a FloyoError with code invalid_option before any request is sent. Listings that hit the API rate limit wait and continue instead of failing.

floyo.workflows.retrieve(workflow, options?)

Retrieves one workflow with every field: description, stats, node metadata and its ComfyUI API prompt. workflow is a workflow id or a workflow from browse().

const workflow = await floyo.workflows.retrieve('workflow_qCfaLLEB51FMpgKZ')

workflow.description // sanitized HTML, can embed images and videos
workflow.stats // { views, likes }
workflow.prompt // { '3': { class_type: 'KSampler', inputs: { … }, _meta: { title } }, … }
workflow.keyInputs // { '47:6': [{ path: 'inputs.text', pathSegments: ['inputs', 'text'], purpose: 'The prompt' }] } or null

for (const pack of workflow.nodesData ?? []) {
  console.log(pack.name, pack.url, pack.nodes)
  for (const model of pack.models)
    console.log(model.name, model.node, model.url)
}

The returned Workflow has the same fields as WorkflowSummary, always present (null when the workflow has none), plus keyInputs. The prompt is returned exactly as the API sends it, so its keys stay in ComfyUI's format. keyInputs lists the inputs declared in a [key-inputs] block of the description, keyed by node id, or null when the workflow declares none. A malformed id rejects with a FloyoError with code invalid_option before any request, and a workflow that does not exist or is not visible to your API key rejects with NotFoundError (code workflow_not_found). options.signal aborts the request.

floyo.workflows.edit(workflow)

Builds a ComfyUI prompt by setting one declared key input at a time. workflow is the object from retrieve(). The published workflow.prompt is left unchanged.

const prompt = floyo.workflows
  .edit(workflow)
  .set('44', 'inputs.image', '#inputs/photo.png')
  .set('47:6', 'inputs.text', 'a cat')
  .prompt()

set(nodeId, path, value) checks that keyInputs declares that node and canonical path, then stores the value. Setting the same node and path again keeps the later value. set returns the editor, so the calls can be chained or written as separate statements. prompt() returns a new prompt with every stored value applied. Inputs you never set keep the value published in the workflow.

set throws a FloyoError with code invalid_option when the node or path is not declared, the node is not in the prompt, nodeId or path is empty, or workflow.prompt is null.

Errors

Every error extends FloyoError and has a code, an optional status and a retryable flag.

| Error | When | | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | InvalidFilesError | Some inputs are missing, empty, unreadable, duplicated or over the limits. issues lists { path, reason }. Nothing is uploaded. | | InvalidInputsError | Some download inputs are malformed, missing or ambiguous. issues lists { index, input, code, reason }. Nothing is downloaded. | | AuthenticationError, PermissionDeniedError | The API key is invalid, revoked or not allowed to transfer files. The whole task rejects. | | InvalidRequestError with code destination_not_found | files.upload() was given a folderId that does not exist, or a missing path with createParents: false. Nothing is uploaded. | | InvalidRequestError with code invalid_destination | files.upload() was given a destination that cannot be a folder (for example, a path through a file). Nothing is uploaded. | | NotFoundError with code folder_not_found | files.list() was given a folder that does not exist. | | NotFoundError with code file_not_found | files.retrieve() was given a file that does not exist, or a folder id. | | NotFoundError with code item_not_found | files.delete() was given a file or folder that does not exist. | | ConflictError with code recursive_delete_required | files.delete() was given a non-empty folder without recursive: true. Nothing is deleted. | | PermissionDeniedError with code item_not_deletable | files.delete() was given an item that is locked. | | PermissionDeniedError with code top_level_folder_not_deletable | files.delete() was given a top-level folder such as inputs or outputs. | | NotFoundError with code workflow_not_found | workflows.retrieve() was given a workflow that does not exist or is not visible to your API key. | | FloyoError with code invalid_option | Thrown synchronously by workflows.edit().set() when a node or path is not a declared key input, or a node is not in the prompt. | | InvalidRequestError, NotFoundError, ConflictError, RateLimitError | Other API errors (APIError subclasses with body and retryAfterMs). | | ConnectionError | A request got no HTTP response (network failure or timeout). | | FileChangedError | A file changed on disk while it was uploading. |

Only invalid inputs and fatal API errors reject the task. Any other failure appears as a failed file result.

import { InvalidFilesError } from '@floyoai/sdk'

try {
  await floyo.files.upload(paths)
} catch (error) {
  if (error instanceof InvalidFilesError) console.table(error.issues)
  else throw error
}

License

MIT