The workspace filesystem

Sudo does not drive a catalogue of per-surface tools. He is handed the workspace as a directory tree and works on it with ordinary commands. That is why he needs no new tool when the product grows a new kind of surface: a new export folder is a new folder.

The mounts

/
├── workspace/       the configuration, exactly as the definitions export writes it   [writable]
├── reference/       a generated example workspace, one well-formed file per surface  [read-only]
├── docs/            one page per code-execution scope, plus the schema helper        [read-only]
├── sdk/             the C# surface your workspace code is written against            [read-only]
├── proc/            live runtime state, regenerated on every read                    [read-only]
├── session/         the assistant's own to-do list                                   [read-only]
├── stage/           the files you uploaded to the Stage area, as text                [read-only]
├── home/uploads/    the files you attached to this conversation                      [read-only]
└── tmp/             scratch, discarded with the session                              [writable]

Only two of them accept writes, and only a write under /workspace is a change to your workspace. That is what makes diff and commit answerable by comparing one directory against the live configuration.

Directories are synthesised from the file names rather than stored. The export is a flat list of paths, and a tree kept beside it could disagree with it, so one list is the source of truth for ls, find, grep -r and the diff alike.

/workspace — the configuration

The same layout as the workspace-definitions export zip, the WebDAV mount and the git-tracked configuration repository. Everything you configure by hand in the admin pages has a file here:

Path Surface Format
code/endpoints/ Custom endpoints .cs
code/chat-ai-tools/ AI tools .cs
code/agents/ Agents .cs
code/skills/ Skills .md
code/ai-assistants/ AI assistants .cs
code/ai-prompt-templates/ Prompt templates .cs
code/ai-providers/ Custom-code AI providers .cs
code/scheduled-tasks/ Custom-code scheduled tasks .cs
code/data-connector-tasks/ In-workspace data connectors .cs
code/migration-tasks/ Migrations .cs
code/indexes/code/ Code indexes .cs
code/indexes/search/ Search code indexes .cs
code/entity-post-processing/ Entity post-processing .cs
config/schemas/nodes/ Node schemas, with their access control .cs
config/schemas/edges.json Edge schemas .json
config/schemas/nodes/styles/ Node styles .json
config/schemas/nodes/rendererers/ Node renderers .json
config/indexes/others/ Other index settings .json
config/chat-policies/ Chat policies .json
config/secrets/ Secret declarations (names only, never values) .json
config/access-groups/ Access-group declarations (names only, never membership) .json
config/packages/ Installed NuGet package declarations .json
config/search.json Search settings .json
config/interface.json Interface settings .json
config/file-indexing.json File-indexing settings .json
config/chat-policy-enforcement.json Chat-policy enforcement settings .json
config/general.json General workspace settings .json
nlp/spotters/, nlp/patterns/, nlp/pipelines/, nlp/linking/ NLP configuration .json

A file whose path the classifier cannot name is a file commit would silently drop, so build refuses it rather than passing it through.

Editing a file changes nothing until you approve it

Writes under /workspace land in the session's own overlay on top of the live configuration. The workspace is untouched until you approve a commit.

A node schema is a C# class

config/schemas/nodes/<Type>.cs declares one node type. The class name is the node type, its properties are the fields spelled the way the graph generates them — List<T> for a list field, Dictionary<string, T> for a dictionary, List<List<T>> for a table — and the access control sits on the thing it restricts:

Invoice.cs
[schema: Curiosity.Schemas.Key("InvoiceNumber")]
[schema: Curiosity.Schemas.AccessGroup("Fn8kQ2mVdRt4wYbLp9sXhC")]
//Access groups: Finance (Fn8kQ2mVdRt4wYbLp9sXhC)

public class Invoice
{
    public string InvoiceNumber { get; set; }

    [Curiosity.Schemas.AccessGroup("Au7dKm3vRp6nWxYbLt2sQe")] //Audit
    public double Total { get; set; }

    public List<string> Tags { get; set; }
}
Attribute Where it goes What it does
[schema: Curiosity.Schemas.Key("…")] the header names the schema's key field
[schema: Curiosity.Schemas.AccessGroup("…")] the header restricts the whole node type to that access group
[Curiosity.Schemas.AccessGroup("…")] a property restricts that one field
[schema: Curiosity.Schemas.InternalNodeType] the header the file carries access control only

A node type the workspace itself owns is not an administrator's to declare, so its file carries the internal marker, declares no schema, and lists only the fields that carry an access group of their own.

A group is named by UID, because that is what survives a rename. The //Access groups: line and the comment beside a field's attribute are regenerated on every export and read by nobody — they are there so a diff naming a group is readable. A node type nobody restricted carries no access attributes at all.

Schemas were JSON, and their access control lived in the general settings

Both halves of one decision are now in the same file. The two dictionaries that used to hold node-type and field access control in config/general.json are kept read-only, so a bundle exported before the change still imports.

Access-group declarations

config/access-groups/<name>.<uid>.json is one access group's declaration: its UID, name, description, whether it is hidden from the pickers users see, and the scope its UID was derived from. Membership never travels. The people in a group belong to the workspace that holds them, and a file rewriting them would hand one deployment's roster to another.

They exist because everything access-controlled — a node type, a single field, a code endpoint, an AI tool, an AI assistant — names its group by UID. Without the declaration, an import installs a restriction pointing at a group the workspace does not have, which fails closed: the restricted thing becomes unreachable for everyone. So they import before anything that can be restricted to one, and:

  • a restriction naming a group the workspace lacks is still applied, and reported as a warning saying which group is missing and where to add it;
  • a group that arrives new is reported too, because an empty group grants nothing until somebody is put in it;
  • deleting an access group by importing a file is refused, because it would revoke access for everyone in it. Remove it under Manage / Access instead.

An import replaces a definition's access groups rather than adding to them, so a file that drops a group from an endpoint, an AI tool or an AI assistant's public access has that applied.

/reference — a worked example of every surface

A generated example workspace, built from the same content classes the real exporter uses, with one well-formed file per kind of surface. It is what Sudo reads when he is unsure what a file of a given kind looks like, instead of guessing from memory, and it is regenerated with the product, so it cannot drift out of date the way a hand-written template would.

/docs — the code-execution scopes

One page per code-execution scope: what an endpoint's scope exposes, what an AI tool's does, what a data connector's does, plus the generated graph-schema helper for your workspace's own node and edge types. The type command serves the same pages for a single type.

/sdk — the C# surface your code is written against

The types and methods that custom endpoints, AI tools, connectors, indexes, scheduled tasks and custom front ends are written against, as files grouped by area (Query/, Endpoints/, Connector/, Search/, AITools/, FrontEndComponents/, …) with an index.tsv to grep.

It is a partial extract by design: signatures and documentation, with method bodies rendered as { ... } and non-surface members left out. The point is to let Sudo get a call right, not to ship the product's source into a chat.

/proc — what the workspace is doing now

Every file here is generated when it is read, in the shape /proc has on Linux:

File Contents
nodetypes, edgetypes node and edge types with counts, key field and field count (TSV)
exceptions the 100 most recent recorded exceptions (JSON)
metrics endpoint and AI-tool call metrics (JSON)
audit recent configuration commits, who changed what and when (JSON)
connectors data connectors and their last run (JSON)
indexes indexes and their status (JSON)
embeddings embedding models and settings (JSON)
scheduled-tasks schedules and last outcome (JSON)
migrations migration tasks, status and progress (JSON)
access-groups access groups (JSON)
logs the tail of the server's own log, newest last (text)
runs/ one folder per thing this session ran: status, log, output, error

Because it regenerates on every read, the same cat run a minute later shows how far a still-running task has got, and jq works on it directly:

jq '.[] | select(.type == "System.NullReferenceException") | .message' /proc/exceptions
awk -F'\t' '$2 > 1000' /proc/nodetypes

Nothing under /proc is configuration and nothing here can be edited or committed.

/stage — the files you handed him

Files uploaded to the private Stage area, served as their text rather than their bytes: a PDF's bytes are no use to a shell, and the indexed text is what answering a question about the upload actually needs. A file nothing has indexed yet reads as a note pointing at the extract command, rather than as an empty file that looks like an empty document.

That is what makes "here is a CSV, build me a connector and a schema for it" a request he can start from.

/home/uploads — the files you attached to this conversation

A file attached to the chat with the composer's attach button is here under its own name, so "have a look at the CSV I just sent you" is cat /home/uploads/customers.csv rather than a hunt for a node.

  • Text and code read as the bytes you uploaded.
  • A document — a PDF, a spreadsheet, a Word file — reads as the text the extractor gets out of it, produced on demand, so a file nothing has indexed yet is still readable.
  • A .zip is a directory. Its structure is read out of the archive itself, so it can be listed, walked and grepped without being unpacked into /tmp first.

The mount is read-only, like /stage: these are your files, not the workspace's configuration, and nothing here is committable. They are stored as ordinary uploads of yours and are deleted with the conversation, so an attachment does not outlive the chat it was meant for.

/session — his own plan

todo.md, rendered from the list the tasks command maintains. It is generated and never parsed, and the mount refuses writes, so tasks is the only way to change it. A plan that could also be half-edited with sed would have two sources of truth and no way to tell which one is current.

The list outlives the conversation, which is what makes a multi-step change survive a session that is picked up again the next day.

/tmp — scratch

Writable, and thrown away with the session. Nothing here is ever part of a commit.

© 2026 Curiosity. All rights reserved.