Open source

Developer guide

Everything you need to understand the platform, navigate the codebase, and ship a contribution.

Platform overview

franki is a terminal-native AI assistant built on an agentic loop. It is not a simple prompt-response tool — it can autonomously read files, edit code, run commands, and search the web until a task is fully complete, asking for permission only on destructive operations.

The platform has four interconnected planes:

┌──────────────────────────────────────────────────────────────────────────┐ │ User interface plane (main.py + ui/) prompt_toolkit REPL · splash + skill bar · streaming render · /commands └────────────────────────────────┬─────────────────────────────────────────┘┌────────────────────────────────▼─────────────────────────────────────────┐ │ Context plane (session.py + skills.py + memory.py + environment.py) system prompt = skill prompt + memory block + env block + project context message window = trimmed history + injected @files + !cmd output + pins └────────────────────────────────┬─────────────────────────────────────────┘┌────────────────────────────────▼─────────────────────────────────────────┐ │ Agent plane (agent/loop.py + agent/tools.py + mcp_client.py) run_agent() → stream → parse tool_calls → execute → loop until done └────────────────────────────────┬─────────────────────────────────────────┘┌────────────────────────────────▼─────────────────────────────────────────┐ │ Provider plane (routing.py + ai_ops.py + providers/) capability score → ordered provider list → stream_chat → fallback on 429 └──────────────────────────────────────────────────────────────────────────┘

Config lives at ~/.config/franki/config.json. Memory lives at ~/.config/franki/memory.json. Sessions export to ~/Documents/franki-sessions/ by default (configurable).

Request lifecycle

This is the exact order of operations from the moment the user presses Enter to the moment the response finishes.

01

Input captured by prompt_toolkit

The REPL reads a line (or multiline block via Alt+Enter). If the input starts with / it routes to handle_command() in commands.py and the lifecycle ends there.

02

Shell execution (!cmd)

If the message starts with !, the rest is run as a shell command. stdout and stderr are captured and appended to the user message before the AI sees it.

03

@ context injection

resolve_content() scans the message for @file, @dir/, @git, @clipboard, and @https:// tokens. Each is replaced with the actual content inline. Images (@image.png) are base64-encoded and sent as vision content blocks.

04

Auto-skill detection

skills.detect_skill() keyword-matches the message against each skill's keyword list. If two or more keywords match a skill other than the active one, the REPL suggests switching. The user must confirm — franki never switches automatically.

05

Auto web search

If the message contains latest, current, today, news, or matches a CVE pattern (CVE-\d{4}-\d+), franki runs a web search via Tavily (preferred) or DuckDuckGo and prepends the results to the user message before sending it to the AI.

06

System prompt assembly

session.get_messages_for_api() builds the full message list. The system message is assembled from: skill prompt + memory block (memory.build_memory_prompt()) + environment block (environment.build_env_block()) + project context (.franki.md if present). The history window is optionally trimmed to the last N user turns.

07

Agent loop entry

run_agent() in agent/loop.py is called with the session, config, and all tool schemas. It enters a loop: call the AI, stream the response, collect tool_call requests, execute each tool, add the results to the message history, then call the AI again.

08

Provider selection

routing.build_routing_order() scores all configured providers against the active skill and returns an ordered list. ai_ops.stream_with_fallback() iterates the list: on a 429 or rate-limit signal it marks the provider as rate-limited (with a cooldown expiry) and moves to the next. Any other error — bad key, unknown model — fails immediately.

09

Tool execution

Each tool_call in the response is dispatched through agent/tools.py execute_tool(). Read-only tools (read_file, list_directory, search_files, grep_files, web_search) can run in parallel. Write and exec tools (write_file, edit_file, run_command, apply_patch) prompt for confirmation unless /auto on is set, or are blocked entirely when sandbox mode is active. All file writes are snapshotted for /undo.

10

Post-response hooks

After the agent loop exits: auto-compact runs if the history token count exceeds the threshold; auto-commit runs if enabled and files were modified; auto-copy sends the response to the clipboard if enabled; cost tracking updates the session totals.

Agent loop

agent/loop.pyrun_agent(cfg, session, ...)

The loop keeps calling the AI until the response contains no tool_call requests:

while True:
    response = stream AI(messages + tools)

    if response has tool_calls:
        for each tool_call:
            confirm if destructive (unless auto_accept)
            result = execute_tool(name, args)
            session.add_tool_result(result)

        continue  ← loop: call AI again with results

    else:
        break  ← task complete

Tool confirmation logic: Each tool call is classified as read-only or destructive. Read-only tools run silently. Destructive tools show a one-line prompt: [y]es / [n]o / [A]lways / [S]kip all. Choosing "Always" sets auto_accept=True for the rest of the session. "Skip all" is equivalent to /sandbox on.

Auto-verify: After any file edit, the loop checks whether a test runner config exists (pytest.ini, package.json scripts, etc.). If one is found, it runs the tests and injects the output so the AI can fix failures automatically.

Sandbox mode: When session.sandbox = True, all write and exec tools are blocked and return an error string to the AI explaining the restriction.

Routing & fallback

routing.pybuild_routing_order(cfg, skill, tracker)

Each configured provider is scored on every request. A higher score means it is tried first. The scoring factors are:

Capability matchEach skill maps to required capabilities. For example, pentest requires reasoning. Providers that declare matching capabilities receive a bonus. Unknown providers get default capabilities inferred from their name.
Latency historyThe RoutingTracker records response times per provider this session. Faster providers score higher.
Local-first bonusIf local_first: true is set in config, Ollama and other local: true providers receive a large score bonus.
Rate-limit exclusionProviders hit by a 429 (or any RATE_LIMIT_SIGNALS string) are excluded for a cooldown window, then re-admitted automatically.
Priority numberEach provider in config can have an integer priority. A higher value produces a higher base score.

Fallback callback: stream_with_fallback() accepts an optional on_fallback(from_label, to_label, reason) callback. The REPL uses it to print the inline notice: ⇄ groq rate limit — switching to gemini/gemini-2.5-flash.

Routing strategy: Configurable via franki config set routing_strategy <value>. Options: capability (default), speed, cost, priority.

Skills

skills.py — built-in skills and auto-detection.

A skill is a system prompt fragment that overrides the AI's default behaviour. The full system prompt is: skill_prompt + _AGENT_RULES + memory + env + project_context.

Built-in skills:

codingGeneral programming assistant. This is the default skill when no other is active.
pentestPenetration testing. Explains tool flags, references MITRE ATT&CK IDs, and assumes an authorized scope.
socSOC analyst. Formats findings as structured reports, maps TTPs, and identifies containment steps.
securityBroad security mode covering CTF, certifications (CEH/OSCP/eJPT), vulnerability research, secure coding, threat modeling, and cryptography.

Auto-detection: detect_skill(message) counts keyword matches across each skill's keyword list. If a skill other than the active one scores two or more matches, it is returned as a suggestion. The REPL shows the suggestion but does not switch automatically — the user must confirm or use /skill.

User-defined skills: Drop a .md or .txt file into ~/.config/franki/skills/. The filename (without extension) becomes the skill name. The file content becomes the system prompt. These are merged with built-ins in get_all_skills().

Agent rules: A constant _AGENT_RULES string is appended to every skill prompt. It instructs the AI to use tools directly and proactively rather than describing what it would do. Never remove this — it is what makes the agent behave correctly.

Memory

memory.py — persistent store at ~/.config/franki/memory.json.

Memory is injected into the system prompt at the start of every session via build_memory_prompt(). It is capped at roughly 500 tokens to avoid bloating the context window.

Four buckets:

factsUser-defined entries from /remember <fact>. Each entry has an ID, text, and timestamp. Remove entries with /forget <id>.
scope_historyThe last 5 pentest scopes set via /scope <ip/cidr>. Automatically injected at the top of the pentest skill block.
skill_usageCumulative count of how many sessions each skill has been used. Not shown to the AI — used for analytics and hints only.
note_historyThe last 10 timestamped entries from /note <text>. Injected as recent findings.

Context injection format: build_memory_prompt() renders something like:

## Long-term memory
Facts you remember:
- I use Python 3.11 and FastAPI
- My pentest lab is 10.10.10.0/24

Active scope: 10.10.10.0/24

Recent notes:
- [2026-05-20 14:32] Found open SMB share on 10.10.10.5

Session

session.py — in-memory state for one REPL session.

The Session object is the single source of truth for everything that changes during a session. The REPL creates one at startup and passes it everywhere.

_messagesFull conversation history as OpenAI-format dicts (role/content).
skillActive skill name. Changed by /skill.
scopeActive pentest target IP/CIDR. Shown in the skill bar.
pinsList of reminder strings prepended to every user message.
sandboxBoolean. Blocks all write and exec tools when true.
routing_trackerRoutingTracker — latency and rate-limit state per provider.
cost_trackerToken input/output counts and estimated cost.
change_trackerFile snapshots taken before each write for /undo and /diff.

Message window trimming: get_messages_for_api() optionally applies two constraints before sending to the API: (1) tool_result_max_chars — any tool result longer than this is truncated with a summary prefix; (2) max_history_turns — only the last N user turns (and their assistant responses) are kept. Both are configurable and default to off.

Branching: /branch save <name> deep-copies the full message list as a named checkpoint. /branch restore <name> replaces the current history with that snapshot, rewinding the conversation to any prior point.

Context injection

Context injection happens in main.py resolve_content() before the message is added to the session.

@file.pyFile content is read and inlined. Supports any text file.
@src/Recursive directory tree and all file contents are concatenated.
@https://urlThe URL is fetched with httpx and its text content is inlined.
@gitRuns git status, git diff, and git log --oneline -10; concatenates all output.
@clipboardReads the system clipboard via pyperclip.
@image.pngImage files are base64-encoded and sent as vision content blocks (requires a vision-capable model).
!commandThe shell command is run via subprocess; stdout and stderr are captured and appended to the message.

Project context (.franki.md): project_context.py walks up from the current working directory looking for a .franki.md file (stopping at ~). If found, its contents are appended to the system prompt in every request. This is the recommended way to give the AI persistent per-project instructions — stack, conventions, architecture decisions. See Project context for the full format.

Agent tools

agent/tools.py — 13 built-in tools exposed to the AI via the OpenAI tools parameter.

Read-only (can run in parallel):

read_fileRead a file. Supports an optional line range. Returns raw text.
list_directoryList files and directories at a path. Supports optional depth and pattern filters.
search_filesGlob-pattern file search. Returns matching paths.
grep_filesRegex or literal search across files. Returns file:line matches.
web_searchTavily or DuckDuckGo search. Returns top result snippets.
check_backgroundPoll the status of a background process started by run_background.

Write / exec (require confirmation unless /auto on is active):

write_fileCreate or overwrite a file. Paths under /etc, /usr, /bin, and similar system directories are blocked.
edit_fileString replacement edit. Takes old_string and new_string. Fails if old_string is not found or is not unique.
run_commandExecute a shell command. Timeout is 30s by default. Returns stdout and stderr.
run_backgroundStart a long-running process in a background thread. Returns a handle ID. Check its status with check_background.
apply_patchApply a unified diff patch string to a file.

Tool result trimming: Results longer than tool_result_max_chars (default 2000) are truncated before being added to the message history. The full result is still used immediately by the current tool call — trimming only affects what the API sees in subsequent turns.

MCP tools: Tools registered via connected MCP servers are returned by get_all_tool_schemas() alongside built-ins. They are dispatched through mcp_client.py.

Custom tools (.franki.md): See Project context.

MCP servers

mcp_client.py — JSON-RPC 2.0 over stdin/stdout subprocess.

franki can connect to any server that implements the Model Context Protocol. Each connection runs as a child process; the tools it exposes are discovered at startup via tools/list and appended to the agent's tool schemas.

# Add an MCP server (stored in config.json under "mcp")
/mcp add my-server
# → prompts for command, args, and env vars

# List connected servers and their tools
/mcp

# Or configure directly in config.json:
{
  "mcp": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "@my-org/mcp-server"],
      "env": { "API_KEY": "..." },
      "enabled": true
    }
  }
}

The MCPClient class manages one connection: it starts the subprocess, performs tools/list discovery on init, and exposes call_tool(name, args). A 10-second timeout prevents a hanging MCP server from blocking the REPL.

Project context & custom tools

Create a .franki.md file in your project root (or any parent directory up to ~). franki finds it automatically and injects it into every system prompt for that directory.

Free-form instructions:

## Project: my-api
Stack: Python 3.11, FastAPI, PostgreSQL
Conventions: snake_case, one class per file, no print() in production code
Testing: pytest + httpx TestClient. Run with `pytest tests/ -v`
Deployment: Docker on Fly.io. Check fly.toml for port and env config.

Custom agent tools via a ```franki-tools code fence:

```franki-tools
[run_tests]
description = Run the project test suite
command = pytest tests/ -v

[deploy_staging]
description = Deploy to staging
command = fly deploy --app my-api-staging

[query_db]
description = Run a read-only SQL query against the dev database
command = psql $DATABASE_URL -c "{query}"
param.query = The SQL SELECT statement to run
```

Custom tools defined this way appear alongside built-in tools in the agent loop. Shell commands support {param_name} substitution. They require the same confirmation as run_command.

How to: add a new provider

Step 1 — Register in KNOWN_PROVIDERS (franki/config.py):

"myprovider": {
    "base_url": "https://api.myprovider.com/v1",
    "suggested_models": ["myprovider-large", "myprovider-fast"],
    "key_url": "myprovider.com/keys",          # shown in franki init wizard
    "key_required": True,
    "capabilities": ["coding", "reasoning"],   # used by routing scorer
    "cost_per_1m_input": 1.0,
    "cost_per_1m_output": 3.0,
    "local": False,
}

Step 2 — Check whether a custom adapter is needed (franki/providers/):

Most providers that follow the OpenAI REST format are handled automatically by providers/generic.py. Only create a new adapter if the provider uses a non-standard protocol. Set "api_type": "myprovider" in the provider dict and add a dispatch case in ai_ops.py _get_stream_fn():

if api_type == "myprovider":
    from franki.providers.myprovider import stream_chat as _fn
    return _fn

A custom stream_chat must be an async def that yields string chunks and raises ProviderRateLimitError on 429.

Step 3 — Update the docs: Add a provider card to the providers page.

Rule: All AI calls must route through ai_ops.stream_with_fallback(). Never call stream_chat directly from a command or feature.

How to: add a new slash command

All slash commands live in franki/commands.py — nowhere else.

Step 1 — Add a dispatch case in handle_command():

if cmd == "/mycommand":
    return _cmd_mycommand(cfg, session, arg)

Step 2 — Implement the handler:

def _cmd_mycommand(cfg: "FrankiConfig", session: "Session", arg: str) -> bool:
    # Do something with cfg and session
    console.print(Text("  done.", style=GOLD))
    return True   # True = redraw prompt, "retry" = re-run last message

Step 3 — Add to /help output: Find _cmd_help() in the same file and add a row to the relevant table section.

Step 4 — Update the docs: Add a row to the appropriate table in the command reference.

Rule: If your command calls the AI, use ai_ops.stream_with_fallback() — never call a provider directly. For a short non-streamed AI response (e.g. generating a one-line answer), use utils/ai.py get_short_response().

How to: add a new skill

Built-in skill (ships with franki):

Add an entry to BUILTIN_SKILLS in franki/skills.py. The value is the system prompt string — always end it with + _AGENT_RULES:

"datascience": (
    "You are Franki in Data Science mode. "
    "Help with pandas, numpy, matplotlib, scikit-learn, and SQL. "
    "Prefer vectorised operations over loops. "
    + _AGENT_RULES
),

Then add auto-detect keywords to _AUTO_DETECT:

"datascience": [
    "dataframe", "pandas", "numpy", "matplotlib", "sklearn",
    "train test split", "feature engineering", "notebook",
],

User skill (personal, not contributed to the repo): Drop a datascience.md file into ~/.config/franki/skills/. The filename (without extension) is the skill name; the content is the system prompt.

How to: add a new agent tool

Built-in agent tools live in franki/agent/tools.py.

Step 1 — Add the tool schema to BUILTIN_TOOLS:

{
    "type": "function",
    "function": {
        "name": "my_tool",
        "description": "What this tool does — the AI reads this to decide when to use it.",
        "parameters": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File path to operate on"},
            },
            "required": ["path"],
        },
    },
}

Step 2 — Add execution in execute_tool():

elif name == "my_tool":
    path = args.get("path", "")
    # ... implementation ...
    return result_string

Step 3 — Classify as read-only or write/exec: Add to either READ_ONLY_TOOLS (runs in parallel, no confirmation required) or WRITE_TOOLS (prompts for confirmation, snapshotted for /undo).

Rule: Tools return plain strings. Errors are also returned as strings — the AI reads them and can retry or report. Only raise an exception for unrecoverable failures (bad imports, etc.).

Dev setup

# Clone and install in editable mode with dev deps
git clone https://github.com/Frank729-ctrl/franki.git
cd franki
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

# Add at least one provider key, then verify the REPL starts
franki init
franki

Requires Python 3.11+. The [dev] extra installs pytest and pytest-asyncio.

Module map

main.pyREPL entry point. CLI arg parsing, REPL loop, MCP lifecycle, auto-search, auto-compact, auto-commit.
commands.pyEvery slash command handler. Single dispatch function handle_command() plus one private _cmd_*() per command.
config.pyPydantic FrankiConfig model. KNOWN_PROVIDERS registry. Load/save helpers.
routing.pyProvider scoring. RoutingTracker for latency and rate-limit state. build_routing_order().
ai_ops.pystream_with_fallback() — the single function every AI call goes through. Also inline AI ops for /compact, /report, /payload, and similar.
skills.pyBuilt-in skill prompts. Auto-detect keyword lists. User skill loader. get_system_prompt().
memory.pyFour-bucket persistent memory. build_memory_prompt() for context injection.
session.pyPer-session state: messages, skill, scope, pins, branches, trackers.
environment.pyLean env block (≤60 tokens): version, cwd, model, skill list, MCP tool count, search status.
project_context.pyWalks up from cwd to find .franki.md.
custom_tools.pyParses ```franki-tools fences from .franki.md into tool schemas.
agent/loop.pyThe agentic loop: stream → parse tool_calls → execute → loop.
agent/tools.py13 built-in tool schemas plus the execute_tool() dispatcher.
mcp_client.pyJSON-RPC 2.0 subprocess client for MCP servers.
oneshot.pyNon-REPL CLI commands: franki fix, review, commit, explain.
cost_tracker.pyToken counting and cost estimation per session.
audit.pyTool execution log for /audit.
change_tracker.pyFile snapshots taken before each write for /undo and /diff.
mitre.pyMITRE ATT&CK mapper used by /mitre.
exporter.pySession export to markdown for /export.
providers/generic.pyUniversal OpenAI-compatible streaming client. Handles most providers.
providers/anthropic.pyAnthropic native API adapter (non-OpenAI format).
providers/cohere.pyCohere adapter.
providers/azure.pyAzure OpenAI adapter (different auth header).
ui/theme.py13 hex colour constants. Every UI module imports from here — never hard-code colours.
utils/highlight.pySyntax highlighting for code blocks in responses.
utils/search.pyTavily and DuckDuckGo web search. Auto-search trigger detection.
utils/ai.pyShort non-streamed AI responses for commands that need a one-shot answer.

Rules

One file per componentNever bundle unrelated classes or features. Each module has a clear single purpose.
No hardcoded keysAll API keys come from ~/.config/franki/config.json via cfg.get_provider_key(name). Never commit secrets.
Slash commands → commands.pyEvery /command goes in commands.py and must appear in _cmd_help().
Providers → providers/ + KNOWN_PROVIDERSNew providers need a registration entry in config.py and optionally an adapter in providers/.
All AI calls via routerNever call stream_chat directly. Use ai_ops.stream_with_fallback(), or utils/ai.get_short_response() for short answers.
Streaming must workNever buffer a full response before displaying. All AI output streams token by token.
_AGENT_RULES in every skillEnd every skill prompt with + _AGENT_RULES. This string is what makes the agent actually use tools rather than describe what it would do.
Tools return stringsTool functions return plain strings, including error messages. Only raise for unrecoverable failures.
Colours from ui/theme.pyNever hard-code hex values in UI code. Import constants from ui/theme.py.

Testing

# Run all tests
pytest tests/ -v

# Run a specific file
pytest tests/test_config.py -v

# Run tests matching a keyword
pytest tests/ -k "routing" -v

Tests use pytest-asyncio for async provider calls. Provider calls in tests should mock the HTTP layer with httpx.MockTransport or respx — do not make real API calls in tests.

Test files: test_config.py (config loading/saving), test_git_injection.py (@git context), test_new_features.py (miscellaneous feature coverage), test_project_context.py (.franki.md discovery).

Submitting a PR

Open a pull request against main. One feature or fix per PR. Fill in the PR template: what changed, how to test it, and whether it touches the routing, agent, or provider layers (these get extra scrutiny).

View on GitHub Open an issue