The consult tool

When a user attaches something to a conversation — a file, a web page, a note, a record of your own schema — the assistant is shown a listing of what is attached: an id, a type, a title and a timestamp for each item, and not its content. consult is how it reads one of them.

consult({"id": "7bWQhKzR2mNvT4pLsXdYcE"})

It is a built-in, and it is the one built-in you are meant to edit. What consulting a File returns is the same everywhere; what consulting an Invoice, a Person or a Support case returns is entirely specific to your workspace, so that is the part you write.

What you write, and what is generated

The tool's stored code is only a list of per-node-type readers. Everything around them is generated when the tool is compiled:

flowchart TD Call["consult({ id })"] --> Parse["Parse the id as a UID128"] Parse --> Exists["Does the node exist?"] Exists --> Access["Can the current user access it?"] Access --> Context["Is it attached to THIS conversation?"] Context --> Dispatch["Dispatch on the node's type"] Dispatch --> R1["ConsultFileEntry"] Dispatch --> R2["ConsultSupportCase"] Dispatch --> R3["…your readers…"] Dispatch --> None["type with no reader →<br/>'cannot be consulted'"]

The generated entry point does four things before your code runs, and none of them is yours to write or to bypass:

  1. parses the id and rejects anything that is not a valid UID;
  2. rejects a UID with no node behind it;
  3. rejects a node the current user cannot access, even when it is attached to the chat;
  4. rejects a node that is not part of this conversation's context.

By the time your reader is called, the item exists, the user may see it, and the user put it there.

The reader schema

Every method whose name starts with Consult is picked up as a reader and must follow exactly this shape:

object Consult<NodeType>(ToolScope scope, UID128 uid) { … }
Rule Detail
Name Consult + an existing node type. The leading _ of an internal type may be omitted, so ConsultFileEntry and Consult_FileEntry both map to _FileEntry.
Parameters Exactly two: a ToolScope then a UID128.
Return Any object. It is serialized to JSON for the model.
One per type Two readers for the same node type is an error, not a race.
No class, no [Tool] Do not declare a class or a tool method — the surface is generated around your readers.

All four rules are checked when you save the tool, against your workspace's real schema, so a typo in a node type name fails immediately with a message naming the method — it does not fail later, in a conversation.

A node type with no reader is not an error: consulting one answers "Items of type 'X' cannot be consulted", which tells the model to stop rather than to retry.

The readers that ship

The seeded code contains three, one per built-in type a user commonly attaches. The file reader is the one worth reading closely — it shows the whole idiom:

object ConsultFileEntry(ToolScope scope, UID128 uid)
{
    var graph = scope.Graph.Internals;

    if (!graph.TryGetReadOnlyContent<_FileEntry>(uid, out var file))
    {
        return new { ok = false, error = "File not found" };
    }

    var pages = Mosaik.AI.ChatAI.GetTextFromNodePerPage(graph, uid);
    var text  = string.Join("\n\n", pages.OrderBy(p => p.Key).Select(p => p.Value));

    scope.SetToolCallDisplayName($"Reading '{Path.GetFileName(file.OriginalName)}'");

    //Adding a snippet lets the interface show the source of the answer next to the tool call
    if (!string.IsNullOrWhiteSpace(text)) scope.AddSnippet(uid: uid, text: text, page: 1, endPage: Math.Max(1, pages.Count));

    return new
    {
        ok          = true,
        uid         = uid.ToString(),
        name        = Path.GetFileName(file.OriginalName),
        contentType = file.ContentType,
        source      = file.Source,
        language    = file.Language.ToString(),
        url         = file.Url,
        pages       = pages.Count,
        text        = text
    };
}

Three habits to copy:

  • ok / error. Answer { ok = false, error = "…" } for anything you cannot read, and write the error for the model: say what to do instead, not just what went wrong.
  • scope.SetToolCallDisplayName(…) — the label the user sees in the chat trace. "Reading 'Q3-forecast.xlsx'" beats "consult".
  • scope.AddSnippet(…) — registers the text as a citation, so the interface can show the source next to the answer. Skip it for a record whose fields are the answer; use it for anything long enough that the user will want to check it.

The other two are short:

object ConsultWebPage(ToolScope scope, UID128 uid)
{
    var graph = scope.Graph.Internals;

    if (!graph.TryGetReadOnlyContent<_WebPage>(uid, out var page))
    {
        return new { ok = false, error = "Web page not found" };
    }

    scope.SetToolCallDisplayName($"Reading '{page.Title}'");

    return new { ok = true, uid = uid.ToString(), title = page.Title, url = page.Url, text = page.TextContent };
}

object ConsultNote(ToolScope scope, UID128 uid)
{
    var graph = scope.Graph.Internals;

    if (!graph.TryGetReadOnlyContent<_Note>(uid, out var note))
    {
        return new { ok = false, error = "Note not found" };
    }

    scope.SetToolCallDisplayName($"Reading '{note.Title}'");

    return new { ok = true, uid = uid.ToString(), title = note.Title, text = Mosaik.AI.ChatAI.GetTextFromNode(graph, uid) };
}

Adding a reader for your own node type

Your node types are schema-defined rather than C# classes, so you read them through the graph and the generated N helpers instead of TryGetReadOnlyContent<T>. A support case, with its fields and the records it links to:

object ConsultSupportCase(ToolScope scope, UID128 uid)
{
    if (!scope.Graph.TryGet(uid, out var node))
    {
        return new { ok = false, error = "Support case not found" };
    }

    var id      = node.GetString(N.SupportCase.Id);
    var summary = node.GetString(N.SupportCase.Summary);

    scope.SetToolCallDisplayName($"Reading case {id}");

    //The records this case links to: what makes a case answerable is rarely on the case itself.
    //scope.Q() queries as the calling user, so a linked record they cannot see is not returned.
    var products = scope.Q()
                        .StartAt(uid)
                        .Out(N.Product.Type)
                        .Take(20)
                        .AsEnumerable()
                        .Select(p => p.GetString(N.Product.Name))
                        .ToArray();

    var body = Mosaik.AI.ChatAI.GetTextFromNode(scope.Graph.Internals, uid);

    if (!string.IsNullOrWhiteSpace(body)) scope.AddSnippet(uid: uid, text: body);

    return new
    {
        ok       = true,
        uid      = uid.ToString(),
        id       = id,
        summary  = summary,
        status   = node.GetString(N.SupportCase.Status),
        opened   = node.Timestamp.ToDateString(),
        products = products,
        text     = body
    };
}

What to return is a design decision, and the same one you make for a tool description: return what answers questions about the item, and nothing else.

  • Include the linked records a reader of the item would want — the customer on an invoice, the products on a case. The model cannot follow an edge itself.
  • Leave out internal identifiers the user will never ask about. Every field costs tokens on a call that is made for one item at a time.
  • Never return what the user cannot see. The generated checks cover the consulted node; a field or a neighbour you reach from it is yours to check. scope.Q() (and scope.Query()) start a query as the calling user, which is what you want for anything you walk out to.
Read-only means read-only

A reader runs on a tool call the model makes on its own initiative. Do not mutate the graph from one, and do not call out to anything with a side effect — a user who attached a document did not ask for anything to happen to it.

Editing the tool

Open Settings → AI → AI Tools → Consult. The editor holds only the readers; saving compiles the generated tool around them and reports a schema error against the offending method.

A release can overwrite your readers

consult is a built-in with a version stamp. A release that changes the shipped readers bumps that stamp, and the workspace then re-seeds the tool, replacing your edits. Keep your readers in your workspace definitions export so they can be restored, and re-check the tool after an upgrade that touches it.

What the tool tells the assistant to do

consult carries a [ToolSystemPrompt] excerpt — one appended to the system prompt of every chat or agent run that offers the tool. It says how the ids are to be handled (22 characters, case-sensitive, copied verbatim, never invented), that only attached items can be consulted, and that an item is consulted before it is answered about rather than assumed. It holds whether or not anything is attached yet, because the tool is enabled by the default workspace picker entry, not only by a conversation that already has context.

Because that rule lives on the tool, you do not repeat it in your assistants' prompts — and an agent run that enables consult gets it too.

  • The search tool — finding an item in the first place. A search result's uid is not consultable unless the user attached it.
  • AI Tools Scope — everything scope offers.
  • Querying the graph — the query surface used in the reader above.
© 2026 Curiosity. All rights reserved.