Onboarding Agents to an Unfamiliar Repo

How to structure an agent's system prompt so it starts with a meaningful working model of a codebase — not a complete one, but enough to reason about what it doesn't know.

•11 min read
llmagentsdependency-analysispvizcontext-management

There's a pattern that shows up repeatedly in agentic coding workflows: the agent starts working on a task, makes a change, and breaks something it didn't know existed. It wasn't careless — it just didn't have enough context about the system before it started acting.

This is the onboarding problem. It applies to human engineers too, but humans have weeks to build their mental model of a codebase. Agents don't get weeks. They get whatever you put in the system prompt.

This post is about how to structure that system prompt so an agent starts with a meaningful working model of the codebase — not a complete one, but enough to reason about what it doesn't know. The same inputs that let a human engineer find their footing in an unfamiliar project can be assembled from a PViz bundle and handed to an agent in a few hundred tokens.

What a working model actually needs

A human engineer onboarding to a new codebase usually does a few things before touching anything: they look at the top-level directory structure, find the entry points, read the README, and then follow the code path for one representative use case end to end.

That's not a random process. It's deliberately building a map before navigating.

An agent can do the same thing if you give it the right inputs. The PViz bundle provides:

  • Folder index — the equivalent of the top-level directory structure, without the noise of every individual file
  • Entry point nodes — modules with role entry_point in the bundle, which is where execution begins and where a human engineer would typically start reading
  • Hotspot nodes — the architecturally significant modules worth understanding early, ranked by how many other modules depend on them transitively
  • The edge list — the dependency paths that let the agent trace a use case from entry point through the relevant layers

A good onboarding prompt loads these strategically, in the right order. The folder index gives the agent its spatial orientation. The entry points tell it where work enters the system. The hotspots tell it which modules it cannot afford to misunderstand.

The onboarding system prompt

Here's the template:

You are a software engineer working on [project name]. Before you start any task,
you should understand the structure of the codebase.

Here is a high-level map of the project:

<folder_index>
[folder index JSON]
</folder_index>

Here are the entry points — the modules where execution begins:

<entry_points>
[entry point nodes]
</entry_points>

Here are the architecturally significant modules — the ones with the most downstream
dependencies:

<hotspots>
[top 5-10 hotspot nodes by transitive closure count, with their imports and exports]
</hotspots>

When you need to understand a specific module in more detail, use the get_node_detail tool.
When you need to find what depends on a module, use the get_dependents tool.
When you need to find what a module depends on, use the get_imports tool.

You have not yet been given a task. Use this context to build your understanding of
the project. If I ask you a question about the codebase before giving you a task,
answer from this context.

The key architectural decision is the split between upfront context and on-demand tools. You load the map — folder index, entry points, hotspots — upfront. You expose the detail as tools. This keeps the initial context lean while giving the agent the ability to explore without requiring you to anticipate every module it might need.

What it looks like in practice

Here's the same structure applied to a hypothetical Celery-like project — constructed to illustrate the pattern, not a description of Celery's actual internals.

Celery is a good test case for this workflow because its architecture isn't obvious from the folder names alone. You have a task registry, an app object, broker abstractions, result backends, a beat scheduler, and worker execution pools — several conceptually distinct subsystems that all need to coexist in the agent's mental model before it touches anything.

The folder index might look like:

{
  "celery/": {
    "app/": "Application factory, task registry, configuration",
    "backends/": "Result storage backends (Redis, database, cache)",
    "beat/": "Periodic task scheduler",
    "contrib/": "Optional integrations (Django, SQLAlchemy, etc.)",
    "loaders/": "App configuration loaders",
    "utils/": "Shared utilities — functional helpers, logging, imports",
    "worker/": "Worker execution: consumer, heartbeat, pool management"
  }
}

The entry point nodes tell the agent where user-facing execution begins: celery/app/base.py (the Celery app object users instantiate), celery/bin/worker.py (the CLI entrypoint for starting a worker), and celery/bin/beat.py (the scheduler CLI). That's a materially different starting set than what you'd get from reading the top-level directory listing — bin/ and app/ look like equals in the folder view; the entry point roles distinguish them.

The hotspot nodes surface what the static structure confirms: celery/app/base.py and celery/utils/functional.py have the highest transitive closure counts, meaning most of the codebase depends on them directly or indirectly. The agent now knows, before being handed any task, that those modules are structurally load-bearing. A change there propagates widely. A misunderstanding there propagates just as widely.

That's the working model. Not complete — the agent hasn't read every file — but oriented.

The tool set

Three tools cover most of what an agent needs during onboarding and task execution:

def get_node_detail(bundle, node_id):
    """Return full detail for a specific node."""
    node = next((n for n in bundle["nodes"] if n["id"] == node_id), None)
    return node or {"error": f"Node {node_id} not found"}

def get_dependents(bundle, node_id):
    """Return all nodes that directly import this node."""
    return [
        e["source"] for e in bundle["edges"]
        if e["target"] == node_id
    ]

def get_imports(bundle, node_id):
    """Return all nodes that this node directly imports."""
    return [
        e["target"] for e in bundle["edges"]
        if e["source"] == node_id
    ]

These are simple graph traversals. Expose them as tools in whatever agent framework you're using. The agent will call them when its reasoning leads it to a module it needs to understand better — rather than asking you to decide in advance which modules to include.

If you're using PViz directly, the MCP server already exposes these traversals as named tools that an agent can call without you writing the adapter layer yourself. The bundle stays server-side; the agent queries it by node ID and gets back only what it asked for.

Warming up the agent

Once the onboarding prompt is loaded, run a brief warmup before handing the agent a real task. Ask it to describe its understanding of the codebase in a few sentences, and ask it to identify any areas it's uncertain about:

Before we start, describe your understanding of how this project is structured
in 3-4 sentences. Then identify one or two areas where you'd want more context
before making changes.

The agent's response tells you whether your onboarding context is actually working. The difference between a good warmup response and a bad one is sharp.

A good warmup response, for the Celery example, looks something like:

This project implements a distributed task queue system. The app/ directory contains the central Celery application object through which users register tasks and configure brokers and backends. Workers (worker/) consume tasks from broker queues, while beat/ handles periodic scheduling. The backends/ directory abstracts over different result storage systems. Before making changes, I'd want more context on how utils/functional.py is used across the codebase — it has very high transitive fan-out, which suggests changes there could have broad effects. I'd also want to understand the boundary between app/ and loaders/, since I'm not yet clear on how configuration flows between them.

This response demonstrates several things the agent got right: it understood the subsystem separation, it correctly identified utils/functional.py as load-bearing from the hotspot data, and it surfaced a genuine architectural ambiguity (the app/–loaders/ relationship) as something worth resolving before acting.

A bad warmup response looks like:

This project appears to be a Python library organized into several subdirectories including app, backends, beat, worker, utils, and contrib. The main entry point seems to be in the app directory. I'm ready to start on your task whenever you are.

This response is describing the folder listing, not the architecture. It hasn't absorbed the structural information — it doesn't mention the entry point roles, doesn't flag utils/functional.py as architecturally significant despite the hotspot data making that explicit, and doesn't identify any uncertainty before diving in. An agent that produces this response hasn't been properly onboarded, and it's worth investigating whether your context was loaded correctly before proceeding.

The warmup isn't just a confidence check. Because the agent has articulated its model explicitly rather than holding it implicitly, it tends to perform better on subsequent tasks — the act of narrating its understanding appears to consolidate it.

Handling large codebases

For codebases with hundreds of nodes, even the hotspot selection needs to be thoughtful. Dumping the top 20 hotspots by transitive closure into the system prompt may not be useful if they're all infrastructure modules unrelated to the agent's likely task domain.

A better approach is to filter hotspots by subdirectory relevance. If the agent will be working primarily in celery/worker/, load hotspots from that subdirectory plus any system-wide hotspots, and skip the rest:

def select_hotspots(bundle, relevant_prefixes, max_nodes=8):
    nodes = bundle["nodes"]

    # System-wide hotspots: high tc relative to the whole graph, short path
    # (short path = near the root, likely shared infrastructure)
    system_hotspots = sorted(
        [n for n in nodes if n.get("tc", 0) > 50],
        key=lambda n: -n.get("tc", 0)
    )[:3]

    # Domain-relevant hotspots: lower tc threshold, but within the task subdirectory
    domain_hotspots = sorted(
        [n for n in nodes
         if any(n["id"].startswith(p) for p in relevant_prefixes)
         and n.get("tc", 0) > 10],
        key=lambda n: -n.get("tc", 0)
    )[:max_nodes - len(system_hotspots)]

    seen = set()
    result = []
    for n in system_hotspots + domain_hotspots:
        if n["id"] not in seen:
            seen.add(n["id"])
            result.append(n)
    return result

The tc > 50 threshold for system-wide hotspots is a starting heuristic, not a fixed rule. In a 200-node codebase, a module with 50+ transitive dependents has roughly a quarter of the graph depending on it — that's a reasonable proxy for "this is load-bearing infrastructure that the agent should know about regardless of task domain." In a 2,000-node codebase, the same threshold might surface dozens of candidates; you'd scale it up accordingly, or switch to a percentile-based cutoff. The tc > 10 threshold for domain hotspots is lower because you want relevant modules from within the task subdirectory even if they're not globally prominent — a module that 12 sibling modules import is still architecturally significant within its own corner of the codebase.

The goal in both cases is the same: give the agent the modules it cannot afford to misunderstand for its likely task scope, without burying that signal in unrelated infrastructure.

The payoff

An agent that's been properly onboarded behaves differently from one that hasn't. It asks better clarifying questions. When it proposes a change, it reasons about side effects rather than making edits in isolation. When it's uncertain, it uses its tools to check rather than guessing. And when it breaks something — which it will, eventually — the break tends to be smaller and more diagnosable, because the agent was working from an accurate map rather than a blank one.

None of that comes from a better model. It comes from better input. The model is the same — what changed is whether it had a map before it started navigating.

This is why the order of operations matters. The prior posts in this series covered using a bundle to scope change impact and to answer targeted codebase questions mid-task. This post is the one that happens first — before the first task, before the first question, before the first line of context is consumed on something the agent was going to get wrong anyway. A properly onboarded agent doesn't just perform better on the first task. It carries a working model forward across the conversation that makes every subsequent interaction cheaper.

That's the underlying argument of the whole series. LLMs don't fail at codebase tasks because they're bad at code. They fail because they're navigating without a map. Structural context — a folder index, entry points, a ranked set of hotspots, a small tool set for on-demand traversal — is what turns a capable model into a capable collaborator on a real repository. If you want to try this against your own codebase rather than a worked example, PViz generates the bundle and exposes the traversal tools through an MCP server, so the wiring described here is ready to use rather than something you have to build from scratch.

Related reading

Try PViz on your own codebase

Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM — for any GitHub repository, in minutes.