cdn.si

rendered markdown titpetric

Workspaces

A workspace is a directory under the root with two directories in it: home/, the HOME of every run in the workspace, and src/, where its projects live side by side, each a checkout of its own, none inside another. The home is a checkout of the workspace’s home repository, what an organisation’s projects share, such as .atkins/skills, a CLAUDE.md with .claude/, and its policies, or an empty directory while the workspace has none. You name the workspace’s folder, and the folder is the namespace of its projects: dockerfiles cloned into the workspace malta lives at <root>/malta/src/dockerfiles, its folder ID is malta/dockerfiles, and it opens at /f/malta/dockerfiles. The home is the folder malta/home, at /f/malta/home.

<root>/malta/                 the workspace
<root>/malta/home/            the home repository's checkout, HOME of every run in malta
<root>/malta/src/dockerfiles/ a project, the folder malta/dockerfiles
<root>/malta/src/apps/        another, malta/apps

Every project is in a workspace. The default workspace, internal, is the root itself, laid out the same: <root>/home is its home, an empty directory task-ui makes, and <root>/src holds the projects cloned at the root, whose pages open under /f/internal/: <root>/src/infra is the folder infra and opens at /f/internal/infra. A link without the internal, such as a bookmarked /f/infra, redirects there for good, its query kept. _ is the default workspace’s former name: the migration moves every stored _ (runs, workflow steps, schedules, secret scopes, notes, pins, env files) to internal, older runs of the Claude jobs that already carried internal now sit in the root with the rest, and a link with /f/_ redirects to /f/internal for good, its query kept. /f/internal lists the projects at the root you can see. task-ui adds the default workspace on its first start, when it has no workspace at all, and makes the home and src of every workspace it has when it starts. The default workspace deletes like any other (see Delete the default workspace). No workspace, project or directory under a workspace takes the name internal, _, home or src; discovery skips a directory of src named internal or _, and a directory of the root or of a workspace that is neither home nor src is not a folder.

The sidebar with the workspace acme open, its projects api and web under it

The home

Every run in a workspace gets the workspace’s home as HOME, always: the server’s HOME is no fallback. The tools a job calls find the workspace’s files there:

The convention for a home repository is a repository named like the organisation: malta/malta is the home of the workspace malta. The Forgejo integration follows it when it syncs a git host, and a workspace made by hand does well to. The tooling of an organisation then has one obvious place.

The workspace’s page, /f/malta, lists the home’s files in its Home section, in the table of the Files section: a directory opens its page, a file opens in the preview window, a dot file and .git list sealed and never open. The home is a folder of its own, malta/home, with the jobs of its atkins.yml when it has one, and @git:pull runs on it as on any checkout; the home is no project: the Projects list, the sidebar and the pins leave it out.

The acme workspace’s page: its Home section lists the home’s files

Whoever commits to the workspace’s home repository controls that workspace’s runs: its .profile runs first in every bash -l, and its skills and CLAUDE.md shape every job and every prompt there. What the instance keeps in the server’s own HOME stays in use, each through a variable the run gets when the server does not set it itself:

What Variable
registry logins of docker login DOCKER_CONFIG, ~/.docker
Claude Code’s settings and sessions CLAUDE_CONFIG_DIR, ~/.claude
the caches of XDG tools, Go’s build cache among them XDG_CACHE_HOME, ~/.cache
Go’s modules GOPATH, ~/go

Everything else of HOME is the workspace’s: the git identity, ~/.gitconfig, the shell history, the XDG configuration, data and state. ssh reads ~/.ssh from the user’s entry in the password database, and its configuration and known hosts stay the server’s. The run also gets TASK_UI_HOME, the home’s directory, the same as HOME.

Skills

The skills of a workspace’s home, its .atkins/skills/, add jobs to every project and directory inside it, each with its page: @git:pull and @git:rebase on each checkout, @compose:down and @compose:up beside a compose file. A skill is an atkins pipeline file, <name>.yml, its jobs named <name>:<job>, and its when: decides where it applies:

A skill job runs in the directory of the file its when: matched: git: pull runs at the top of the checkout and compose: up beside the compose file. One without when: runs in the folder that holds its .atkins/.

<root>/malta/home/.atkins/skills/compose.yml:

name: Docker Compose
when:
  files: [compose.yml, compose.yaml]
  here: true

jobs:
  up:
    desc: Start the services
    cmd: docker compose up -d --remove-orphans
  down:
    desc: Stop and remove the services
    cmd: docker compose down
  build:
    desc: Build the service images
    when:
      files: [compose.yml, compose.yaml]
      here: true
      contains: '(?m)^\s+build:'
    cmd: docker compose build
  custom:
    desc: Run docker compose with arguments
    args: true
    steps:
      - exec: [docker, compose]

In a directory whose compose.yml builds an image, the @compose rows are build, custom, down and up; beside one that only pulls images, they leave build out. atkins -l in a directory lists what applies there, as the sidebar and the folder’s jobs do. The atkins skills guide covers the rest: aliases, help:, the -l --json fields task-ui reads, vendoring.

Add, edit and delete

Settings, Workspaces manages them: Allow View on Workspaces, workspace/view, opens the page, Allow Edits, workspace/edit, adds, configures and orders, and Allow Deletes, workspace/delete, removes one. Export and Import are an admin’s: the bundle holds the workspace’s private keys and secrets. The page lists each workspace with its home repository, or “empty”, how many projects are inside it, the checkouts under its src/ and the project rows there, each once, the names in the number’s tooltip, and how many secrets it holds, shared and its folders’, the number opening its Secrets page, in the sidebar’s order.

Workspaces, each with its home repository and projects

Delete the default workspace

An instance runs without a default workspace. Without internal, the Workspaces page lists the named workspaces alone, and task-ui makes no home/ or src/ at the root again. What changes:

Like any workspace, internal deletes once it holds no secret and no run of the root is active: delete its variables by hand first, then Delete.

Clone a project into one

Add project under Settings, Projects has a Workspace select. internal, the default, while the instance has it, clones the project into src/ at the root; a named workspace clones it into its src/, as malta/dockerfiles. The Projects page lists it under that name. In the API an empty workspace means internal, and answers 400 on an instance without a default workspace. A project may not take a workspace’s name, home, src or a directory that’s there already: that answers 409, or 400 for one of task-ui’s names, before git runs.

A checkout in the workspace’s src/ without a project row lists too, when it holds a job source anywhere inside it, such as malta/deploy with its scripts/atkins.yml: it opens at /f/malta/deploy with its jobs and the folders inside it, as a project does (see Where the jobs come from). A checkout that a project’s .gitignore ignores right below it lists the same way.

Recursive, on the workspace’s add and configure forms as on a project’s, is on by default: discovery lists the folders inside the workspace as deep as they go. Off, it lists the workspace’s top-level folders alone, and the projects inside it by their own Recursive.

The API does the same, with a token from task-ui token:

curl -s -X POST https://task.example.com/api/workspaces \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"folder": "malta", "title": "Malta", "url": "git@example.com:org/workspace.git"}'
curl -s -X POST https://task.example.com/api/projects \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"url": "git@example.com:org/dockerfiles.git", "workspace": "malta"}'
curl -s -X PUT https://task.example.com/api/workspaces/malta/home \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"url": "git@example.com:org/workspace.git", "branch": "main"}'

The create’s url is optional: with it, the home is a clone of that repository at branch, its default branch when left out; without it the home is an empty directory. The reply is {workspace, output}, git’s output in output. A taken folder answers 409. PUT /api/workspaces/malta with {title, description} changes its text, recursive its Recursive, and DELETE /api/workspaces/malta removes it. PUT /api/workspaces/malta/home with {url, branch} replaces the home with a clone, DELETE /api/workspaces/malta/home empties it, and both answer 409 while a run of the workspace is active. A workspace’s row names its home repository in home_url and home_branch, "" for an empty home. A sort in the PUT places the workspace: the lowest comes first, and workspaces of one sort go by folder. Left out, the sort stays. A sidebar_limit in the PUT is the Sidebar setting, 0 for every project; below 0 answers 400, and left out it stays.

In the sidebar

The projects at the top of the root come first, by name, and the workspaces follow them, in their order. A workspace adds a level above its projects: a label row with the workspace’s name, the count of its projects and a chevron that points down while it’s open. Under it come the workspace’s own jobs, then its projects, each with a fold of its own: every directory right inside the workspace’s src/, a checkout with jobs or without; the home never shows in the sidebar, pinned or not; its files are on the workspace’s page. This browser remembers each fold apart, and malta and malta/dockerfiles open and close on their own.

A workspace with many projects lists the ones in use: Sidebar in its settings takes a number, and the sidebar lists that many of the workspace’s projects, the ones with the most recent commit first, a checkout without a commit last, and ties by name; the folders inside a kept project stay with it, and the listed projects keep the sidebar’s name order. Empty or 0 lists every project, by name. A project set not to show takes no place in the count. The rest leave the sidebar alone: the Projects page, the workspace’s page, the dashboard and a pin list them as before. The default workspace, internal, takes the setting for the projects at the root. task-ui reads each project’s last commit with one git command and keeps it a minute. A page costs at most one git process per project per minute, and a pull or a push shows in the order within the minute. Pin workspace in the [...] of the workspace’s page, and Pin on a folder or a job inside it, add to what the sidebar lists, for you (see What the sidebar lists). With no named workspace, the sidebar lists the projects alone (see Projects and jobs).

Runs in a workspace

A run of a job in the workspace or in one of its projects gets two more variables (see Runs):

A run at the root of an instance without a default workspace gets none of them: HOME stays the server’s.

Before the job, the run pulls a home that holds a checkout, then the project’s checkout, and the shared tooling is current when the job starts. When either pull fails, the run fails with it.

Hours

Hours on a workspace’s settings page says when runs in the workspace may start: every job of its folders and projects, the built-ins such as @shell too. One rule per line, days then a span; the last rule that covers a day decides it:

mon-fri 9-17
fri 9-13

runs Monday to Thursday nine to five, Friday mornings, and never on the weekend. Days are names or ranges (mon-fri, sat,sun, weekdays, daily); spans are hours or hh:mm. Empty, the default, allows any time. The workspace is the one place hours live: a project and a job take their workspace’s, and the default workspace’s apply to the projects at the root.

Out of hours Run fails, and Run force asks why: it needs a reason, keeps it with the run, and every list of runs shows it. A schedule and a prompt go ahead at any time: the schedule is the decision, and a prompt runs none of the folder’s jobs.

Keys

Every workspace has SSH keys of its own, in the key folder, TASK_UI_SSH_DIR, out of its directory, where a git add -A would publish them: workspace_<folder>_<name>_<algorithm>.key and .pub, the files 0600. The Keys section of a workspace’s settings page, /settings/workspaces/malta, lists them in the keys table of the Keys page, name, algorithm, size, public key with its copy button, purpose and the ⋯ menu, never a private key. With none, it says “No keys added.” beside Upload key and Generate key.

SSH keys, in the ⋯ menu at the top right of the workspace’s page, /f/malta, opens /settings/workspaces/malta/keys, the workspace’s keys alone, added in place: Generate key and Upload key at the top right open their forms in a dialog over the list, and Remove retires a key. There is no form that asks whose runs get one: a key added here is a key of this workspace, so every run of this workspace gets it. The page and the menu item take workspace/view, the forms workspace/edit, as the key routes do.

The workspace page’s menu with SSH keys and Secrets

The SSH keys of acme, edited in place

A key has one use (see One key, one use): an upload of key material the key folder holds already, under any name, answers 409.

A workspace’s settings page: its home repository, keys, and Export

A run gets the instance keys linked to it (see Settings), then its workspace’s SSH keys, and the first line of its terminal names each: $ ssh-agent: deploy_rsa4096, workspace_malta_deploy_ed25519. A project at the root gets the keys of internal. The links decide what reaches a run’s agent; a job can still open any key file on disk (see What a workspace keeps apart).

curl -s -X POST https://task.example.com/api/workspaces/malta/keys/generate \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"algo": "ed25519", "name": "deploy", "policies": ["admin"]}'
curl -s https://task.example.com/api/workspaces/malta/keys \
  -H "Authorization: Bearer $TOKEN"

PUT /api/workspaces/malta/keys/workspace_malta_deploy_ed25519 with {policies, users} sets the links, empty for every run, and DELETE retires the key. "encrypt": true on generate or upload answers 400: one encryption key per instance. Listing the keys takes workspace/view; the other key routes take workspace/edit.

Secrets and env

A workspace’s variables reach its runs (see Settings). A variable of scope workspace:malta reaches every folder in it; a project’s own, scope malta/deploy, reaches that project, and a run there reads the project’s entry of a name the workspace also holds. A variable of scope all reaches every workspace. The variables of internal reach the projects at the root and no named workspace unless their scope is all: a project in malta gets malta’s own variables, the all ones and its own, never internal’s. The instance’s encryption key encrypts them all.

Secrets in the ⋯ menu at the top right of the workspace’s page opens /settings/secrets?workspace=malta, the variables that reach that workspace, on the one page that manages them. It takes secret/view; adding, editing and deleting take secret/edit and secret/delete.

Move to another instance

A workspace moves to another task-ui as a bundle, <folder>.tui.age: a tar.gz of manifest.json and .ssh/, encrypted with age to a passphrase of at least 12 characters. Whoever holds the file and the passphrase holds the workspace’s variables and SSH keys: the passphrase is their only protection inside it, and task-ui keeps no copy of the passphrase. To move a workspace, export it, import the bundle on the other instance, then delete its variables here by hand, which Delete needs, and then the workspace.

The bundle carries:

It never carries the instance’s encryption key, the instance’s SSH keys or another workspace’s keys. Schedules, workflows, runs and artifacts stay on the instance, as do the integrations. The bundle of internal leaves out the named workspaces.

Export at the top right of a workspace’s settings page, which an admin sees, opens a dialog for the passphrase, typed twice, or made by Generate: 24 random characters, shown once with a copy button. Store it before you export, since nothing recovers it. Export in the dialog then downloads the bundle; the passphrase goes in the request body, never in the URL or the logs. Import at the top right of the Workspaces page opens a dialog for the file, the passphrase and the folder to import it as, the bundle’s own when left out. The folder must be free, as for Add workspace: a folder held answers 409. The import makes the workspace’s directory with its home and src, writes the keys 0600 into the key folder, adds the rows under the folder, clones the home repository into the home, clones each project into src, then writes the env files into the folders the clones made. A bundle of another format answers 400: the format is 4, and an older bundle does not import. A project that does not clone keeps its row, and the import reports git’s output for it. Any failure before the projects’ clones rolls the import back: no directory, no keys, no rows stay. Env files and secrets that a removed workspace of the same folder left behind take the bundle’s content.

The import refuses a key it would hold twice, and renames none. A bundle whose SSH key this instance holds, under any name, or whose key name the key folder holds answers 409 with the key’s name before it clones anything. A workspace exported and imported again on the same instance needs the original removed first, or another instance.

The bundle of the default workspace, internal, imports as a named workspace and needs a folder, such as legacy. Its projects move under that folder, web becomes legacy/web, and its shared secrets become legacy’s; the workspace’s title is the folder.

curl -s -X POST https://task.example.com/api/workspaces/malta/export \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"passphrase": "correct horse battery"}' -o malta.tui.age
curl -s -X POST https://other.example.com/api/workspaces/import \
  -H "Authorization: Bearer $OTHER_TOKEN" \
  -F bundle=@malta.tui.age -F passphrase='correct horse battery' -F folder=malta

The import answers {workspace, home, projects, unknown_users}: the home and each project with cloned and git’s output, and the users the grants name that the instance doesn’t have. Their grants stay and reach a run once a user of that name exists. An export’s passphrase under 12 characters, a wrong passphrase on import, a file that is not a bundle, and an entry other than manifest.json or a .key or .pub file of .ssh/ answer 400; bundled keys held here answer 409, and a file over the upload limit 413. Both routes take an admin, 403 for anyone else, with or without workspace/edit: the bundle alone, of everything task-ui hands out, holds private keys, inside its passphrase, and the import writes them into the key folder.

Limit Size
The bundle file 64 MiB
manifest.json 16 MiB
Each key file 1 MiB
Entries 1024

What a workspace keeps apart

A workspace decides what task-ui puts into a run: which secrets go into its environment and which SSH keys go into its agent. That is no sandbox. Every run runs as the server’s OS user, in every workspace, and a job can read what that user can:

A job that someone in one workspace can change can read another workspace’s secrets, and whoever commits to a workspace’s home repository controls that workspace’s runs: its .profile, skills and CLAUDE.md run in every one of them. Runner rights, and the right to push to a workspace’s repositories, its home repository first, belong with people you would trust with every workspace on the instance; where that does not hold, run one instance per trust boundary.

A prompt against a workspace

A workspace’s folder page, /f/malta, carries the Prompt box open for a user with Allow Edits on Prompts, prompt/edit, once the Claude Code integration has a token for them; every other folder page, and every directory inside a folder, carries it folded until its directory holds a prompt (see Prompt). The prompt runs @claude-prompt of the workspace as you, and Claude’s answer shows under the question when the run ends.

The Prompt box on a workspace’s page

Claude Code works in the workspace’s home and reads the workspace’s CLAUDE.md and .claude/, in an empty home too. The run’s environment holds the thread in TASK_UI_PROMPT, the home in TASK_UI_HOME, the workspace’s directory in TASK_UI_WORKSPACE_DIR, and the file Claude writes its answer to in TASK_UI_PROMPT_REPLY. task-ui agent prompt works in TASK_UI_PROMPT_DIR, which a prompt of a folder or a directory sets, or else in TASK_UI_HOME, or else in TASK_UI_WORKSPACE_DIR, and exits with an error when the prompt or all three are empty. The run lists under Runs as the workspace’s, malta/@claude-prompt.

curl -s -X POST https://task.example.com/api/workspaces/malta/prompt \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"prompt": "list the skills this workspace carries"}'

The reply is the note, with its id, and its run_id once the run starts; Claude’s answer is a reply to it, which GET /api/prompts?folder=malta lists. The run starts in the workspace’s home and holds it as POST /api/prompts holds one: a second prompt there waits for it, and a prompt in a project of the workspace runs beside it.


Previous: Projects and jobs · Next: Recents · Contents