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>}
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 alocked flag (migration 0005_preference_lock, default false).
- The agent path respects the lock.
update_preferencesandremove_preferencesread 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 withrespect_lock=True. - The user path ignores the lock. The REST endpoints in
workspace.pycall the repository withrespect_lock=False, andPATCH /preferences/{id}accepts alockedfield to lock/unlock. Locked means “the agent cannot modify or remove it; only the user can”. - Both the auto-injected
<preferences>workspace block and thelist_preferencestool 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
-
Create a new file (e.g.
tools/citations.py) with a*Toolsclass that takesthread_idin__init__and exposes@workspace_toolmethods. -
Append an instance to
_collections_forinmanager.py:
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, omitargs_schema(the decorator defaults to the empty_NoArgsschema). - Artifacts are persisted to the
artifactstable and streamed to the client via dedicated SSEartifactevents (separate fromstepevents); the agent never re-emits the payload verbatim.