Everything you need to understand the platform, navigate the codebase, and ship a contribution.
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:
Config lives at ~/.config/franki/config.json. Memory lives at ~/.config/franki/memory.json. Sessions export to ~/Documents/franki-sessions/ by default (configurable).
This is the exact order of operations from the moment the user presses Enter to the moment the response finishes.
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.
!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.
@ context injectionresolve_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.
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.
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.
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.
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.
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.
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.
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.py — run_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.py — build_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 match | Each 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 history | The RoutingTracker records response times per provider this session. Faster providers score higher. |
| Local-first bonus | If local_first: true is set in config, Ollama and other local: true providers receive a large score bonus. |
| Rate-limit exclusion | Providers hit by a 429 (or any RATE_LIMIT_SIGNALS string) are excluded for a cooldown window, then re-admitted automatically. |
| Priority number | Each 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.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:
| coding | General programming assistant. This is the default skill when no other is active. |
| pentest | Penetration testing. Explains tool flags, references MITRE ATT&CK IDs, and assumes an authorized scope. |
| soc | SOC analyst. Formats findings as structured reports, maps TTPs, and identifies containment steps. |
| security | Broad 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.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:
| facts | User-defined entries from /remember <fact>. Each entry has an ID, text, and timestamp. Remove entries with /forget <id>. |
| scope_history | The last 5 pentest scopes set via /scope <ip/cidr>. Automatically injected at the top of the pentest skill block. |
| skill_usage | Cumulative count of how many sessions each skill has been used. Not shown to the AI — used for analytics and hints only. |
| note_history | The 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.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.
| _messages | Full conversation history as OpenAI-format dicts (role/content). |
| skill | Active skill name. Changed by /skill. |
| scope | Active pentest target IP/CIDR. Shown in the skill bar. |
| pins | List of reminder strings prepended to every user message. |
| sandbox | Boolean. Blocks all write and exec tools when true. |
| routing_tracker | RoutingTracker — latency and rate-limit state per provider. |
| cost_tracker | Token input/output counts and estimated cost. |
| change_tracker | File 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 happens in main.py resolve_content() before the message is added to the session.
| @file.py | File content is read and inlined. Supports any text file. |
| @src/ | Recursive directory tree and all file contents are concatenated. |
| @https://url | The URL is fetched with httpx and its text content is inlined. |
| @git | Runs git status, git diff, and git log --oneline -10; concatenates all output. |
| @clipboard | Reads the system clipboard via pyperclip. |
| @image.png | Image files are base64-encoded and sent as vision content blocks (requires a vision-capable model). |
| !command | The 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.py — 13 built-in tools exposed to the AI via the OpenAI tools parameter.
Read-only (can run in parallel):
| read_file | Read a file. Supports an optional line range. Returns raw text. |
| list_directory | List files and directories at a path. Supports optional depth and pattern filters. |
| search_files | Glob-pattern file search. Returns matching paths. |
| grep_files | Regex or literal search across files. Returns file:line matches. |
| web_search | Tavily or DuckDuckGo search. Returns top result snippets. |
| check_background | Poll the status of a background process started by run_background. |
Write / exec (require confirmation unless /auto on is active):
| write_file | Create or overwrite a file. Paths under /etc, /usr, /bin, and similar system directories are blocked. |
| edit_file | String replacement edit. Takes old_string and new_string. Fails if old_string is not found or is not unique. |
| run_command | Execute a shell command. Timeout is 30s by default. Returns stdout and stderr. |
| run_background | Start a long-running process in a background thread. Returns a handle ID. Check its status with check_background. |
| apply_patch | Apply 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_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.
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.
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.
ai_ops.stream_with_fallback(). Never call stream_chat directly from a command or feature.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.
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().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.
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).
# 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.
| main.py | REPL entry point. CLI arg parsing, REPL loop, MCP lifecycle, auto-search, auto-compact, auto-commit. |
| commands.py | Every slash command handler. Single dispatch function handle_command() plus one private _cmd_*() per command. |
| config.py | Pydantic FrankiConfig model. KNOWN_PROVIDERS registry. Load/save helpers. |
| routing.py | Provider scoring. RoutingTracker for latency and rate-limit state. build_routing_order(). |
| ai_ops.py | stream_with_fallback() — the single function every AI call goes through. Also inline AI ops for /compact, /report, /payload, and similar. |
| skills.py | Built-in skill prompts. Auto-detect keyword lists. User skill loader. get_system_prompt(). |
| memory.py | Four-bucket persistent memory. build_memory_prompt() for context injection. |
| session.py | Per-session state: messages, skill, scope, pins, branches, trackers. |
| environment.py | Lean env block (≤60 tokens): version, cwd, model, skill list, MCP tool count, search status. |
| project_context.py | Walks up from cwd to find .franki.md. |
| custom_tools.py | Parses ```franki-tools fences from .franki.md into tool schemas. |
| agent/loop.py | The agentic loop: stream → parse tool_calls → execute → loop. |
| agent/tools.py | 13 built-in tool schemas plus the execute_tool() dispatcher. |
| mcp_client.py | JSON-RPC 2.0 subprocess client for MCP servers. |
| oneshot.py | Non-REPL CLI commands: franki fix, review, commit, explain. |
| cost_tracker.py | Token counting and cost estimation per session. |
| audit.py | Tool execution log for /audit. |
| change_tracker.py | File snapshots taken before each write for /undo and /diff. |
| mitre.py | MITRE ATT&CK mapper used by /mitre. |
| exporter.py | Session export to markdown for /export. |
| providers/generic.py | Universal OpenAI-compatible streaming client. Handles most providers. |
| providers/anthropic.py | Anthropic native API adapter (non-OpenAI format). |
| providers/cohere.py | Cohere adapter. |
| providers/azure.py | Azure OpenAI adapter (different auth header). |
| ui/theme.py | 13 hex colour constants. Every UI module imports from here — never hard-code colours. |
| utils/highlight.py | Syntax highlighting for code blocks in responses. |
| utils/search.py | Tavily and DuckDuckGo web search. Auto-search trigger detection. |
| utils/ai.py | Short non-streamed AI responses for commands that need a one-shot answer. |
| One file per component | Never bundle unrelated classes or features. Each module has a clear single purpose. |
| No hardcoded keys | All API keys come from ~/.config/franki/config.json via cfg.get_provider_key(name). Never commit secrets. |
| Slash commands → commands.py | Every /command goes in commands.py and must appear in _cmd_help(). |
| Providers → providers/ + KNOWN_PROVIDERS | New providers need a registration entry in config.py and optionally an adapter in providers/. |
| All AI calls via router | Never call stream_chat directly. Use ai_ops.stream_with_fallback(), or utils/ai.get_short_response() for short answers. |
| Streaming must work | Never buffer a full response before displaying. All AI output streams token by token. |
| _AGENT_RULES in every skill | End 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 strings | Tool functions return plain strings, including error messages. Only raise for unrecoverable failures. |
| Colours from ui/theme.py | Never hard-code hex values in UI code. Import constants from ui/theme.py. |
# 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).
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).