AI programming assistant that runs in the terminal, powered by local models via Ollama. Features persistent sessions, long-term memory, autonomous tool execution, and parallel subagent orchestration.
┌──────────────────────────────────────────────────┐
│ julia> create a REST server with 3 endpoints │
│ │
│ 🔀 Complex task — spawning 3 subagents... │
│ → Subagent: endpoint GET /users │
│ → Subagent: endpoint POST /users │
│ → Subagent: endpoint DELETE /users/:id │
│ ✅ 3 completed, no failures │
└──────────────────────────────────────────────────┘
- Node.js >= 18
- Ollama running locally (
http://localhost:11434)
npm i -g juliacodejuju # start chat
juju --session <id> # resume existing sessionjuju --gateway # default: 127.0.0.1:18800
juju --gateway --host 0.0.0.0 --port 3000 # custom host/portEndpoints:
| Method | Route | Description |
|---|---|---|
GET |
/health |
Health check |
GET |
/sessions |
List sessions |
POST |
/sessions |
Create session |
GET |
/sessions/:id |
Session details |
GET |
/sessions/:id/messages |
Session messages |
POST |
/chat |
Chat (full response) |
POST |
/chat/stream |
Chat (SSE streaming) |
Chat event streams can include clear_streaming when a tentative model response
must be discarded and warning when Julia cannot recover a promised action.
Both events are additive; existing thinking, chunk, tool, done, and error
events keep their current payloads.
Julia has access to 10 tools that it executes autonomously:
| Tool | Description |
|---|---|
exec |
Run shell commands (git, npm, etc.) |
read |
Read files with line numbers |
write |
Create/overwrite files |
edit |
Replace text segments in files |
glob |
Search files by glob pattern |
grep |
Search content with regex |
fetch |
Access URLs, APIs, and web pages |
memory |
Persistent memories across sessions |
sessions |
Manage saved sessions |
subagent |
Orchestrate parallel subagents |
When enabled, Julia automatically detects complex, parallelizable tasks and spawns independent subagents with their own sessions. Each subagent can use a different model.
Orchestration Run (run_id)
├── SubagentRun 1 — web scraper [gpt-oss:120b-cloud] completed 2.3s
├── SubagentRun 2 — csv processor [qwen3:8b] completed 1.8s
└── SubagentRun 3 — api server [qwen3.5:397b-cloud] completed 3.1s
All runs are persisted in SQLite with status lifecycle (queued → running → completed/failed), timestamps, and duration.
To connect a new MCP server, edit ~/.juliacode/settings.json and add the mcpServers section:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"],
"env": {}
}
}
}Each entry in mcpServers is an MCP server with:
| Field | Required | Description |
|---|---|---|
command |
yes | Command to start the server |
args |
no | Array of arguments (default: []) |
env |
no | Extra environment variables for the process |
Example with multiple servers:
{
"models": { "default": "qwen3:8b" },
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"]
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_TOKEN": "ghp_yourtoken" }
},
"sqlite": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sqlite",
"/path/to/database.db"
]
}
}
}When Julia Code starts, it connects to each server and automatically registers their tools. The agent will see tools named like mcp__filesystem__read_file,
mcp__github__create_issue, etc. It can use them normally during conversation. To remove a server, just delete the entry and restart.
{
"models": {
"provider": "ollama",
"baseUrl": "http://localhost:11434",
"default": "qwen3:8b"
},
"agent": {
"maxToolIterations": 10
},
"session": {
"compactionThreshold": 6000,
"compactionKeepRecent": 6
},
"storage": {
"dbPath": "./data/julia.db"
},
"acp": {
"enabled": false,
"autoOrchestrate": false,
"maxConcurrent": 3,
"subagentMaxIterations": 15,
"defaultModel": null
},
"memory": {
"semantic": {
"enabled": false,
"provider": "ollama",
"embeddingModel": "nomic-embed-text",
"rankingWeights": { "similarity": 0.6, "importance": 0.3, "recency": 0.1 },
"recencyHalflifeDays": 30,
"maxMemories": 5,
"availabilityCheckTtlMs": 30000,
"autoBackfillOnStart": false
}
},
"security": {
"allowRules": [],
"rateLimits": {
"enabled": true,
"perTool": {
"exec": { "perMinute": 20, "perSession": 200 }
}
}
}
}Every tool call is checked against a per-session budget before it runs. The limits exist to stop a runaway loop — not to ration ordinary use — so the defaults are deliberately loose:
| Tool | Per minute | Per session |
|---|---|---|
exec |
20 | 200 |
fetch |
30 | 300 |
subagent |
5 | 40 |
| everything else | 60 | 600 |
Override any of them under security.rateLimits.perTool, or set security.rateLimits.enabled to false to turn the check off entirely.
When a tool exceeds its budget the call is refused and the reason is handed back to the model as a tool observation, so it can wait, change approach, or answer with what it already gathered. The budget is enforced independently of approval: approving a tool — even with "approve all" — waives the prompt, not the quota. The command blocklist is checked first, so a blocked command stays blocked regardless of remaining budget.
Quota refusals show up in /stats under Security gate.
Every step of a turn is written as one JSON line to ~/.juliacode/logs/events.jsonl. Events from the same user turn share a turnId, so two turns in the same session stay separable:
| Event | Records |
|---|---|
llm_call |
model, pass (main / synthesis / correction), duration, tokens in/out, tool-call count |
tool_call |
tool name, success, duration |
gate_decision |
tool name, outcome, and which rule decided it |
compaction |
kind (auto / emergency), messages compacted, tokens before/after |
memory_retrieval |
candidates, returned, top score, provider availability |
retry |
kind (stream, empty, deterministic, tool-correction, intent-nudge) |
diagnostics |
project-check result and duration |
planner_decision, subagent_spawn, subagent_done |
orchestration lifecycle |
loop_end |
iteration count and why the turn ended |
Run /stats to see these aggregated. Set JULIA_DEBUG=1 to mirror every event to stderr, or JULIA_LOG_DIR to write elsewhere. Logging is fire-and-forget and never affects control flow.
Drop your own skills into ~/.juliacode/skills/ to extend Julia. They are loaded on every session, globally — no need to duplicate per project.
Layout follows the same pattern as Claude Code: one directory per skill, with a SKILL.md file inside.
~/.juliacode/skills/
├── review-pr/
│ └── SKILL.md
└── deploy-checklist/
└── SKILL.md
Each SKILL.md is a Markdown document with optional YAML frontmatter:
---
name: review-pr
description: Review a pull request against the team conventions
when_to_use: User asks to "review PR" or pastes a diff
argument_hint: <pr-number-or-url>
user_invocable: true
expects_tools: true
---
Your skill prompt body goes here. Use $ARGUMENTS to inject the user-provided argument.Behavior:
- The skill name comes from the
namefrontmatter; if absent, it defaults to the directory name. - Skills with
user_invocable: trueshow up as slash commands (e.g./review-pr). expects_toolsdefaults totrue; dialogue-only skills can set it tofalseto disable intent-without-action recovery for their turns.- All custom skills are loaded into the system prompt under a
User-Defined Skills (LOWER TRUST)section — they cannot override system instructions. - On name collision with a built-in default skill, the default wins.
- Each
SKILL.mdis limited to 50 KB and is scanned for prompt-injection patterns before being loaded; rejected files are logged and skipped. - Subdirectories without a
SKILL.mdare skipped and logged.
Configure shell commands that fire at specific points of the agent loop. The schema mirrors Claude Code's hooks system 1:1.
⚠️ Hooks run arbitrary shell commands with your user's permissions on every matching event. Audit any hook you add. Julia does not sandbox them.
{
"hooks": {
"PreToolUse": [
{
"matcher": "exec",
"hooks": [
{ "type": "command", "command": "audit-shell.sh", "timeout": 5000 }
]
}
],
"UserPromptSubmit": [
{ "hooks": [{ "command": "echo 'Reminder: review security'" }] }
]
}
}Supported events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, Notification, PreCompact.
Each hook receives a JSON payload on stdin (session_id, cwd, hook_event_name, plus event-specific fields like tool_name, tool_input, prompt, source, etc.). Control Julia via:
- Exit code 0 — success. If stdout is JSON
{ "decision": "block" | "approve", "reason": "...", "hookSpecificOutput": { "additionalContext": "..." } }, Julia honors it. ForUserPromptSubmit,SessionStart, andPreCompact, raw stdout is also accepted and injected as additional context. - Exit code 2 — blocking error.
stderrbecomes the block reason and is surfaced to the agent. - Other non-zero — non-blocking error. Logged via the MCP log channel; agent continues.
The matcher field is a regex tested against the tool name for PreToolUse / PostToolUse. Omit it (or use *) to match every tool. For non-tool events the field has no effect.
Anti-loop guard: when a Stop or SubagentStop hook returns decision: "block", Julia re-enters the loop once and re-fires the hook with stop_hook_active: true. The hook is expected to respect that flag on the second pass.
The Julia environment exposes JULIA_HOOK=1 and JULIA_HOOK_EVENT=<event> to every hook process — useful for detecting recursion or branching logic. Default timeout per command is 60 s and can be overridden with the timeout field (in ms). ~/.juliacode/settings.json itself is read-only for Julia's own tools, so the hooks block must be edited by hand — the same workflow used for mcpServers.
With memory.semantic.enabled: false (default), Julia injects the 30 most-recent memories into the system prompt, just like before.
With memory.semantic.enabled: true, Julia uses embeddings (via Ollama nomic-embed-text) to rank memories by relevance to the current user input. Flow:
- Pull
nomic-embed-textonce:ollama pull nomic-embed-text. - Flip
memory.semantic.enabledtotruein~/.juliacode/settings.json. - Run
juju memory backfillto populate embeddings for existing memories. - Set
memory.semantic.autoBackfillOnStart: trueif you want new boots to resume backfilling automatically.
If the embedding provider is unavailable at any point (Ollama down, model missing, request fails), Julia degrades transparently to the legacy recent-memories injection — the app never breaks because of a missing embedding.
Julia can build a local semantic index of your project so that relevant code is automatically pulled into the LLM context — an offline equivalent of Cursor's repo-aware feature, powered by the same Ollama nomic-embed-text model used for memories. Nothing leaves the machine. Two pieces:
Automatic semantic retrieval. Every turn, the user's prompt is embedded and ranked against indexed code chunks by cosine similarity; the top 5 (token-budgeted, same-file chunks merged) are injected as a system block alongside memories. Gated by a heuristic that skips greetings, meta-questions, and short prompts without code-like tokens.
@filename mentions. Reference any file by typing @<path> in your message — the file's content is expanded inline before the prompt is sent. Tab-complete via the fuzzy dropdown that appears when you type @. If the path doesn't exist, fuzzy matching against the index salvages typos (e.g., @app.tsx → src/tui/app.tsx). Mentions inside fenced or inline code blocks are ignored, absolute paths and ../ traversal are rejected, files >50 KB are truncated, and binaries are refused.
Indexing flow:
- Pull
nomic-embed-textonce:ollama pull nomic-embed-text. - On first startup, the auto-indexer builds the index in the background from
git ls-files(respects.gitignore, skips binaries and files >1 MB, caps at 5 000 files). - Use
/indexto re-index incrementally,/index forceto rebuild from scratch,/index statusto inspect meta,/index abortto cancel an in-progress run.
Storage. Chunks (80 lines with 20-line overlap) live in the code_chunks table with content/file hashes for fast incremental re-index — unmodified files skip chunking entirely, and chunks whose content didn't change preserve their existing embeddings.
Degradation. If Ollama is down at index time, chunks are inserted without embeddings and the next /index resumes from there. If Ollama is down at query time, the retrieval block is omitted silently — @filename mentions still work because they're pure file I/O. The index drifting from the current HEAD triggers a one-time stale hint suggesting a /index refresh.
juju.ts # Entry point (CLI)
src/
├── agent/
│ ├── loop.ts # Agent loop (LLM ↔ tools)
│ ├── subagent.ts # Subagent manager + orchestration
│ ├── queue.ts # Execution queue
│ └── context.ts # Context building + compaction
├── config/
│ ├── index.ts # Config loading
│ └── workspace.ts # Workspace directory
├── gateway/
│ └── server.ts # HTTP REST API
├── providers/
│ ├── registry.ts # Provider registry
│ └── ollama.ts # Ollama provider
├── session/
│ ├── db.ts # SQLite schema (7 tables)
│ └── manager.ts # CRUD sessions, messages, memories, runs
├── skills/
│ ├── loader.ts # Skills loader
│ └── defaults/ # Built-in skills (base, coder, memory, subagent)
├── tools/
│ ├── registry.ts # Tool registry
│ ├── exec.ts, read.ts, ... # Implementations
│ └── subagent.ts # Subagent tool
└── tui/
└── app.tsx # Terminal interface (React + Ink)
SQLite with WAL mode. 9 tables:
- sessions — conversations with title, model, tokens
- messages — user/assistant/tool messages with tool_calls
- compactions — summaries of old context
- memories — persistent memories with categories
- orchestration_runs — subagent batches with status/duration
- subagent_runs — individual tasks with full lifecycle
- code_chunks — indexed source chunks with embeddings + content hashes
- code_index_meta — singleton key/value store for index metadata (HEAD sha, last run, model)
| Layer | Technology |
|---|---|
| Runtime | Node.js (ESM) |
| Language | TypeScript |
| UI | React 18 + Ink |
| Database | SQLite (better-sqlite3) |
| LLM | Ollama |
| Tests | Vitest |