GoShipped

πŸ€– Agents

An agent reaches a goal across multiple steps. GoShipped does that with the planner, the tool loop, and Deep Research β€” not a generic Agent class.

A tool does one thing. An agent reaches a goal.

The agent can decide which tools to use and in which order, inspect the results, and continue until it can answer.

If you only need one specific action, such as calling an API or querying your database, create a tool instead.

Tool  = do one thing
Agent = reach a goal
Tools define capabilities.
Prompts define when to use them.
The planner coordinates them.
Deep Research is a separate workflow for larger research tasks.

GoShipped does not ship a generic Agent class you subclass. Agent behavior is the chat pipeline: a planner, registered tools, a classic tool-call loop, and a separate Deep Research workflow.

What is an agent?

In GoShipped, β€œagent” means: the model is allowed to gather information with tools before it answers.

Typical flow on the default chat path:

User goal
    ↓
Planner (AgentPlan)
    ↓
Choose tools
    ↓
Execute (execute_tool_call)
    ↓
Inspect results
    ↓
Synthesis β€” or continue via the tool loop if the planner declined
    ↓
Final answer

The planner produces a structured AgentPlan (goal, needs_tools, steps[]). Each step names a tool from the current registry. Steps run sequentially through the same execute_tool_call path as a single tool.

If the planner is disabled, returns None, or has no valid steps, chat falls back to the classic tool-call loop in apps/api/app/ai/tools/agent_loop.py. That loop lets the model call tools for up to TOOL_MAX_STEPS (default 16), then streams the answer.

When should I use an agent?

Use agent behavior when one function call is not enough:

  • Research a company across several sources
  • Compare products, then read the best pages
  • Analyze data with more than one tool
  • Decide dynamically what to do next
  • Gather evidence, then synthesize a recommendation

A single weather lookup or β€œwhat plan am I on?” should stay a tool.

When should I use a tool instead?

If the work is one action with a clear input and a JSON result, write a tool. Register it. Teach the prompts when to call it.

Agent setup is for goals. Tool setup is for verbs. See Tools.

How agents work in GoShipped

A chat turn is routed first, then executed.

Chat request (mode: auto | default | deep_research)
        ↓
Mode router  β†’  default path or Deep Research
        ↓
Default: planner β†’ execute plan β†’ synthesis
         (fallback: classic tool-call loop)
        ↓
Deep Research: LangGraph workflow (plan β†’ research β†’ evaluate β†’ synthesize)
PiecePathRole
Mode routerapps/api/app/ai/planner/mode_router.pyFor mode: "auto", pick default or deep_research
Plannerapps/api/app/ai/planner/planner.pyStructured AgentPlan from the available tool list
Planner promptsapps/api/app/ai/planner/prompts.pyWhen to use which tool, plan size, routing rules
Plan executionapps/api/app/ai/planner/execute_plan.pyRun steps via execute_tool_call
Synthesisapps/api/app/ai/planner/synthesis.pyFinal answer from tool results (tool_choice=none)
Classic loopapps/api/app/ai/tools/agent_loop.pyFallback: model picks tools until it answers
Tool registryapps/api/app/ai/tools/registry.pyWhich tools exist for this request
Deep Researchapps/api/app/ai/workflows/deep_research/Separate multi-step research graph

The planner runs when AGENT_PLANNER_ENABLED=true (code default). If it fails or decides no tools are needed, chat continues on the normal loop.

Deep Research is not β€œmore tools”. It is its own LangGraph workflow. It still calls execute_tool_call, but it is selected by the mode router (or an explicit mode: "deep_research"), not by registering a research tool.

Create your first agent

There is no class CompanyResearchAgent. You shape agent behavior by composing the pieces GoShipped already has.

This walkthrough assumes you already created a custom tool named search_company_database (Tools). The Agents page covers what happens after that tool exists.

Goal: research a company using internal company data plus current public information, then synthesize the result.

search_company_database
        ↓
tool_registry / get_available_tools
        ↓
build_planner_system_prompt()
        ↓
create_agent_plan()
        ↓
AgentPlan
        ↓
execute_tool_call()
        ↓
synthesis

web_research already covers open-web evidence. The custom tool is only for data you store.

1. Define the goal

Write the user-facing job in one sentence. That is what the planner restates as AgentPlan.goal.

Research Acme Corp. Use our internal company data and current public information.

2. Register the tool once

There is no separate agent registry. Add the instance to the same tool_registry as every other built-in tool (apps/api/app/ai/tools/registry.py):

# apps/api/app/ai/tools/registry.py
from app.ai.tools.tools import search_company_database_tool
from app.ai.tools.types import Tool

tool_registry: dict[str, Tool] = {
    # ...existing tools...
    search_company_database_tool.name: search_company_database_tool,
}

Export the instance from apps/api/app/ai/tools/tools/__init__.py first, the same way the Tools tutorial does.

Once get_available_tools() returns the tool, the planner automatically receives its name, description, and argument schema via _format_tool_entry(). You do not pass tools into a second agent registry.

3. Teach the planner when to use it

Default chat voice lives in the conversation system prompt (AI Chat). Tool routing is separate.

Chat has two execution paths, so routing lives in two prompt files. The planner path uses build_planner_system_prompt() in apps/api/app/ai/planner/prompts.py. If the planner is off or returns None, the classic tool loop still runs with the system prompt that already includes build_tools_system_section() from apps/api/app/ai/tools/prompts.py (assembled in _build_llm_messages() in apps/api/app/services/chat_service.py). Edit both so the same tool is routed the same way on either path.

Abbreviated excerpt of the real planner function. Add your line in the Tool routing block of the returned f-string β€” do not create a new file:

def build_planner_system_prompt(
    tools: list[Tool],
    *,
    max_steps: int,
    has_uploaded_files: bool = False,
) -> str:
    tool_lines = "\n".join(_format_tool_entry(tool) for tool in tools)
    # ...memory_routing, mcp_routing, uploaded_files_rules...

    return f"""You are a planning agent for a chat assistant with access to tools.
Before any tool execution, decide whether tools are needed and produce a minimal plan.

Available tools (use ONLY these names β€” never invent tools):
{tool_lines}

Rules:
- Set needs_tools=false when the user question can be answered directly without external data
  (explanations, opinions, general knowledge, coding help, simple math).
# ...existing rules (needs_tools=true, max steps, args schema)...

Tool routing:
{memory_routing}{mcp_routing}- web_research: current events, niche facts, reviews, news, comparisons,
  pricing, and open research when no single target URL should be fetched for content.
  Do NOT use web_research when the user provides one URL and wants to read, summarize,
  extract, or understand that page β€” use read_url.
# ...existing routing for read_url, stocks, shopping...
- get_weather: weather or forecast questions.
- search_company_database: use for internal customer, account, or company data.
  Prefer this before web_research when the answer may already exist internally.
{uploaded_files_rules}
When needs_tools=false, return an empty steps array and optionally set
final_response_instructions with guidance for the direct answer.

Return ONLY valid JSON matching this schema:
# ...AgentPlan JSON schema from the shipped function...
"""

The # ... comments mark cuts. The shipped function is longer.

The fallback loop uses a different prompt shape β€” Use <tool> when ... sentences inside build_tools_system_section():

def build_tools_system_section(*, routing_hints: list[str] | None = None) -> str:
    # ...hint_text, feature-flagged memory_line...

    return f"""## Tools
You have access to tools.
{memory_line}Use get_weather when current weather or a forecast (e.g. tomorrow or the next days) is needed.
# ...existing Use get_stock_quote / get_stock_news / check_domain_availability /
# compare_product_prices rules...
Use search_company_database when the question is about customers, accounts,
or company records you already store. Prefer it before web_research for
internal data. Pass company as the company name.
{hint_text}{_WEB_RESEARCH_ROUTING}Use web_research whenever current, recent, external, factual, product, review, news, pricing, market, company, technology, or other web-based information is required and the user is not asking to read or summarize one specific URL (e.g. 'recent updates about X', 'current user experiences with X', 'latest developments regarding X', 'current reviews of X'). Do not use it for personal memory, calculations, internal application state, conversation memory, or single-URL page content (summarize/read/extract that URL via read_url). For a concrete product's current price / cheapest offer / where to buy, prefer compare_product_prices over web_research.
# ...Do not invent tool results / evidence-only web_research sentences...
"""

Registration makes the tool callable. These two prompts teach the model when to call it.

4. What the planner produces

This is a realistic AgentPlan for:

Research Acme Corp. Use our internal company data and current public information.

web_research takes question (required) β€” not query. This walkthrough assumes your custom args_model has a required company: str field. If you named that argument something else, the args in the plan must use that name.

{
  "goal": "Research Acme Corp using internal and public information",
  "needs_tools": true,
  "steps": [
    {
      "id": "step-1",
      "tool_name": "search_company_database",
      "args": {
        "company": "Acme Corp"
      },
      "reason": "Check internal company data",
      "depends_on": []
    },
    {
      "id": "step-2",
      "tool_name": "web_research",
      "args": {
        "question": "Recent developments at Acme Corp"
      },
      "reason": "Find current external information",
      "depends_on": []
    }
  ],
  "final_response_instructions": "Combine internal and public findings into one concise answer."
}

That matches AgentPlan / PlanStep in apps/api/app/ai/planner/types.py: goal, needs_tools, steps[] with id, tool_name, args, reason, depends_on, plus final_response_instructions.

You do not normally construct this JSON yourself. create_agent_plan() asks the model for the structured plan and validates it against the currently available tools. The example above is only to show what the planner is actually producing.

Inside create_agent_plan() (apps/api/app/ai/planner/planner.py), available tools become the planner system prompt:

async def create_agent_plan(
    messages: list[ChatMessage],
    available_tools: list[Tool],
    provider: LLMProvider,
    context: ToolContext | None = None,
    *,
    model: str | None = None,
    max_steps: int | None = None,
) -> AgentPlan | None:
    # ...empty-tools guard and URL / MCP short-circuits...

    system_prompt = build_planner_system_prompt(
        available_tools,
        max_steps=limit,
        has_uploaded_files=has_uploaded_file_context(messages),
    )
    # ...JSON LLM call, parse, _validate_and_sanitize_plan...

Most customers never call create_agent_plan() themselves. The chat pipeline already does it in try_planned_turn() (apps/api/app/ai/planner/planned_turn.py):

tools = await get_available_tools(context)
plan = await create_agent_plan(
    llm_messages,
    tools,
    provider,
    context,
    model=model,
)

available tools β†’ planner β†’ AgentPlan. If the planner returns None or no valid steps, chat falls back to the classic tool loop.

5. How the plan is executed

create_agent_plan(...)
        ↓
AgentPlan.steps
        ↓
execute_agent_plan_with_events(...)
        ↓
execute_tool_call(...) per step
        ↓
build_synthesis_messages(...)
        ↓
final answer

try_planned_turn() runs execute_agent_plan_with_events(plan, context, allowed_tools=tools), then synthesizes with build_synthesis_messages() and a final chat_completion(..., tool_choice="none").

Each planned step becomes a ToolCall in apps/api/app/ai/planner/execute_plan.py:

tool_call = ToolCall(
    id=f"plan-{step.id}-{uuid.uuid4().hex[:8]}",
    name=step.tool_name,
    arguments=step.args,
)
tool_result = await execute_tool_call(
    tool_call, context, allowed_tools=allowed_tools
)

Send the question in chat and watch plan_created, then tool_call_start / tool_call_done. You do not subclass an Agent to get this path.

6. Optional: expose the tool to Deep Research

Only if you also want this tool available inside Deep Research. A tool in tool_registry is not automatically on that path.

Add its name to RESEARCH_TOOL_ALLOWLIST in apps/api/app/ai/workflows/deep_research/nodes/_helpers.py:

# apps/api/app/ai/workflows/deep_research/nodes/_helpers.py
RESEARCH_TOOL_ALLOWLIST = frozenset(
    {
        "web_research",
        "read_url",
        "get_stock_quote",
        "get_stock_news",
        "search_memory",
        "search_company_database",
    }
)

_research_tool_allowed() then includes that name. Skip this for the default planner / tool loop.

Most custom agent behavior also does not require changing the mode router (_SYSTEM_PROMPT in apps/api/app/ai/planner/mode_router.py). Everyday company lookups stay on the default path. Broad multi-source research may select Deep Research when mode is auto. Words like β€œresearch” alone must not trigger it.

7. Test and refine

  • Unit-test the tool under apps/api/tests/ai/tools/
  • Planner routing: apps/api/tests/ai/planner/ (create_agent_plan with a fake provider)
  • Mode router: apps/api/tests/ai/planner/test_mode_router.py
  • Deep Research graph: apps/api/tests/ai/workflows/deep_research/

Tighten prompts when the planner picks the wrong tool.

I create capabilities as tools.
I register those tools once.
The planner automatically sees available tools.
I teach the planner when to use them.
The planner creates an AgentPlan.
GoShipped executes the selected tools.
The synthesis step writes the final response.
Deep Research is optional and separate.

A concrete execution example

User:
Research Acme Corp. Use our internal company data and current public information.

Default planner:
1. search_company_database(company="Acme Corp")
2. web_research(question="Recent developments at Acme Corp")
3. synthesis combines both results

Deep Research (broad competitive / multi-source request):
1. Research planner writes a multi-step plan
2. Researcher searches, reads sources, optionally searches again
3. Evaluator checks coverage; may request additional research
4. Synthesizer writes the answer from evidence only

The model must not invent web results. web_research returns ranked evidence with source URLs, not a finished essay.

Prompt, tools, limits

Agent behavior is mostly those three knobs:

KnobWhere
Goal / instructionsConversation system prompt, planner prompts, synthesis prompt, Deep Research node prompts
Available toolstool_registry + get_available_tools (and Deep Research allowlist)
LimitsTOOL_MAX_STEPS (default path), Deep Research iteration / tool-call caps, plan entitlements

Plans still gate Deep Research (deep_research_enabled β€” Pro on, Free off in the shipped catalog) even when features.deepResearch is true.

Planner and Deep Research

Planner (default path)

When AGENT_PLANNER_ENABLED=true, chat builds an AgentPlan from the current available tool list before the tool loop.

User message
  β†’ Planner (structured AgentPlan)
  β†’ execute planned tool steps (sequential)
  β†’ final LLM synthesis

Validation: tool_name must exist in the available list, args must match args_model, step count is capped. Native read_url short-circuits single-URL summarize/read/extract requests. Open-web questions go to web_research.

If the planner is disabled or fails, the classic loop still sees the same tools.

Deep Research (separate workflow)

Deep Research sits beside the classic agent loop. It does not replace it.

Request modeWhat runs
auto (default)Backend routes to default or deep_research
defaultPlanner / classic tool loop
deep_researchLangGraph research graph

The graph: planner β†’ optional human approval β†’ researcher β†’ evaluator β†’ additional research (cycle) β†’ synthesizer.

It needs explicit state, retries, checkpointing, and optional Human-in-the-Loop. Normal chat stays on the planner + tool loop so everyday turns stay fast.

Turn the whole product capability off with features.deepResearch: false. Technical knobs (DEEP_RESEARCH_MAX_ITERATIONS, DEEP_RESEARCH_MAX_TOOL_CALLS, DEEP_RESEARCH_HITL_MIN_STEPS, checkpoint backend) live in apps/api/.env.

Disabled vs failed routing are different (resolve_execution_mode in mode_router.py):

  • If automatic routing fails, times out, or returns invalid output, GoShipped falls back to the default chat path.
  • If Deep Research is disabled, mode: "auto" stays on the default path. The router does not select Deep Research.
  • An explicit mode: "deep_research" request while Deep Research is disabled raises FeatureDisabled("deep_research"). It does not silently downgrade.

On this page