Skip to main content
Tools live under backend/app/tools/. A tool is not a plain @tool-decorated function. It is a coroutine method on a *Tools class, decorated with @workspace_tool(name, description, args_schema). The @workspace_tool decorator wraps the method, and collect_tools discovers every decorated method on a collection instance and turns it into a LangChain StructuredTool. build_tools(thread_id) instantiates one collection per domain and flattens them into the list passed to create_react_agent. See backend/app/tools/README.md for the canonical reference. Each *Tools instance is bound to a single thread_id, so every tool reads/writes the workspace (objective, document, todos, preferences, artifacts) for that one conversation without thread_id being an LLM-facing parameter.

Per-domain reference

This page is the shared cheat-sheet. Each tool domain also has a dedicated reference page that documents every tool in full: its arguments, return values, the description shown to the agent (the LLM-facing prompt text), and the implementation.

Document

Write the shared working document: update_document, append_to_document.

Objective

Record the agent’s one-line understanding of the goal: set_objective.

Plan

The execution ledger: add_todos, toggle_todos, update_todos, remove_todos, list_todos.

Preferences

The hard and soft constraint set: add_preferences, update_preferences, remove_preferences, list_preferences.

Charts

A typed line, bar, or pie chart artifact: chart_generator.

Past artifacts

Reference earlier artifacts by id: list_artifacts, get_artifact.

Built-in tools

There are 15 LLM-facing tools, composed from six collections in _collections_for: DocumentTools, ObjectiveTools, TodoTools, PreferenceTools, ChartTools, ArtifactTools. Every mutating workspace tool is batch-shaped: it takes a list so the agent can act on many items at once; to act on a single item, pass a one-element list. There are no singular variants. Every tool additionally gets a required injected action_and_reasoning: str argument (added by the decorator via _extend_with_common_arg). The agent must populate it with one short, non-technical sentence; it is logged and surfaced in the frontend’s “Internal reasoning” trace, but is never forwarded to the tool body. Tool results are returned as a JSON envelope string, not free-form prose:
  • Success: {"success": true, "summary": <content>, "artifact": <dict>?, "artifact_id": <int>?}
  • Failure: {"success": false, "error": <str>}
A tool body returns either a plain str (content only) or a tuple (content_str, artifact_dict) where artifact_dict = {"artifact_type": str, "payload": dict, "title"?: str}. chart_generator is the artifact-producing tool: it returns a tuple with artifact_type = f"{chart_type}_chart" and a payload of {title, description, data, x_axis_title, y_axis_title, style}. When an artifact dict is present, the decorator persists it via repo.add_artifact(...) tagged with the current turn_index and surfaces the new row id as artifact_id.

Preference locking

Each preference carries a locked flag (migration 0005_preference_lock, default false).
  • The agent path respects the lock. update_preferences and remove_preferences read the preferences first; if a target is locked they do not mutate it and instead skip and report it: that the preference is locked and cannot be modified (update_preferences) or removed (remove_preferences), and that the user must unlock it first, with no exception raised. The repository enforces this with respect_lock=True.
  • The user path ignores the lock. The REST endpoints in workspace.py call the repository with respect_lock=False, and PATCH /preferences/{id} accepts a locked field to lock/unlock. Locked means “the agent cannot modify or remove it; only the user can”.
  • Both the auto-injected <preferences> workspace block and the list_preferences tool append (locked) to locked rows.

Adding a tool

There is no @tool, no app/tools/__init__.py registration step, and no ToolManager.discover() glob. The recipe (see backend/app/tools/README.md):

Extend an existing domain

Add a @workspace_tool method to the relevant *Tools class. The body must follow the signature contract async def fn(self, repo: WorkspaceRepository, **typed_kwargs):
collect_tools picks it up automatically; no other file changes.

Add a new domain

  1. Create a new file (e.g. tools/citations.py) with a *Tools class that takes thread_id in __init__ and exposes @workspace_tool methods.
  2. Append an instance to _collections_for in manager.py:
No other file needs to change.

Conventions

  • All mutations go through the WorkspaceRepository / DB, so the frontend re-syncs the affected panel.
  • The decorator opens its own AsyncSession (async_session_factory) per call, injects the repo, and commits on success / rolls back on any exception, so tool bodies stay free of session management.
  • Use typed parameters (not bare **kwargs) so the LLM-facing args schema is unambiguous. For tools with no inputs, omit args_schema (the decorator defaults to the empty _NoArgs schema).
  • Artifacts are persisted to the artifacts table and streamed to the client via dedicated SSE artifact events (separate from step events); the agent never re-emits the payload verbatim.