Building o11n: From Idea to Desktop App

How a simple idea became a local-first AI code-orchestration tool


o11n started as an attempt to stop repeating context every time I worked with an AI assistant. Pasting code into chat windows works for small experiments, but fails the moment you have cross-file dependencies. The core requirement became clear: treat a project as a conversational object and do everything locally. That design led to three major components: prompt synthesis in TypeScript, structured change plans from the model, and a controlled application pipeline in Tauri + Rust.

From prototype to system

The earliest version simply loaded files, assembled them into a rough prompt, and asked the model for changes. The model began suggesting multi-file edits once it had full project visibility, proving the approach viable. The challenge was not capability, but safety. Models can propose refactors; code cannot afford ambiguous action. The system needed deterministic execution and transparent review.

The first piece to stabilize was how the prompt is assembled.

Prompt generation

Prompt construction is handled entirely on the UI side. The intent is simple: build a deterministic text representation of the project slice the user wants to operate on, add any templates or formatting instructions, and append explicit instructions. There is no hidden context. The user sees exactly what the model sees.

The Copy component orchestrates this. It collects selected files, optionally generates a Markdown directory tree, reads each file from disk, emits them in fenced code blocks, merges optional templates, and finally adds user instructions.

const buildPromptText = useCallback(async (): Promise<string> => {
  const lines: string[] = [];

  let filePaths = selectedFiles.map((file) => file.path);
  if (filePaths.length === 0 && projects?.length) {
    filePaths = projects.map((project) => project.path);
  }
  const fileMap = generateFileMap(filePaths);
  if (includeFileTree) {
    lines.push("## File Map");
    lines.push("```");
    lines.push(fileMap);
    lines.push("```");
    lines.push("");
  }

  if (selectedFiles.length > 0) {
    lines.push("## File Contents");
    for (const file of selectedFiles) {
      let content = await readTextFile(file.path, {
        baseDir: BaseDirectory.Home,
      }).catch(() => "/* Error reading file */");

      const markdownExtension = getMarkdownLanguage(getExtension(file.path));
      if (markdownExtension !== "image") {
        lines.push(`**File:** ${file.path}`);
        lines.push("```" + markdownExtension);
        lines.push(content);
        lines.push("```");
        lines.push("");
      }
    }
  }

  if (customTemplates?.length) {
    const activeTemplates = customTemplates.filter((t) => t.active);
    for (const template of activeTemplates) {
      const content = await readTextFile(template.path, {
        baseDir: BaseDirectory.Home,
      }).catch(() => "/* Error reading template */");
      const markdownExtension = getMarkdownLanguage(getExtension(template.path));
      lines.push(`**File:** ${template.path}`);
      lines.push("```" + markdownExtension);
      lines.push(content);
      lines.push("```");
      lines.push("");
    }
  }

  if (formatOutput) {
    lines.push("## Additional Formatting Instructions");
    lines.push("```");
    lines.push(formattingInstructions);
    lines.push("```");
  }

  lines.push("## User Instructions");
  lines.push("```");
  lines.push(instructions);
  lines.push("```");

  return lines.join("\n");
}, [...]);

The prompt is cached to avoid rebuilding unnecessarily. Token counting happens through a Tauri bridge (count_tokens), so the user can see the approximate context footprint before submitting.

The critical aspect is that this step is deterministic. Prompt content depends only on the selected files, active templates, formatting settings, and user instructions. Nothing else is injected. It forms the contract that drives the rest of the workflow.

Once a prompt is generated and sent to the model, the response is expected to conform to a structured change format.

Structured change plans

The model returns a plan expressed in a Markdown-like protocol. Each file block specifies a path, an action, and one or more change segments containing a description, the search region, and replacement content. The format is terse, human readable, and easy to inspect before committing.

The plan is not executed directly. Rust first parses it into strongly typed structures:

Action::{Modify, Rewrite, Create, Delete}
Change { description, search, content }
FileChange { path, action, changes }

If the result cannot be parsed unambiguously, the plan is rejected before any filesystem interaction.

Parsing the protocol

The parser recognizes file boundaries, action headers, change segments, and code-fenced search and content regions. Intermediate buffers are flushed as structured data. The design prioritizes predictability and early failure.

pub fn parse_change_protocol(xml_protocol: &str) -> Result<Vec<FileChange>> {
    let mut file_changes: Vec<FileChange> = Vec::new();
    let mut current_file_path: Option<String> = None;
    let mut current_action: Option<Action> = None;
    let mut current_changes: Vec<Change> = Vec::new();
    let mut current_description = String::new();
    let mut current_search: Option<String> = None;
    let mut current_content = String::new();
    let mut reading_field: Option<String> = None;
    let mut in_code_block = false;
    let mut code_field: Option<String> = None;
    let mut code_lines: Vec<String> = Vec::new();
    let mut skip_current_file = false;

    let stripped = xml_protocol.replace("<pre>", "").replace("</pre>", "");
    let lines: Vec<&str> = stripped.lines().collect();
    log::debug!("Stripped {}", stripped);

    for line in lines {
        /* ... iteration and block processing ... */
    }

    Ok(file_changes)
}

Malformed structures are surfaced to the UI. This keeps failures explainable rather than silently producing broken edits.

Local execution and Tauri

o11n runs entirely on the user’s machine. Tauri provides the boundary: TypeScript drives interaction; Rust owns execution. There is no remote service and no code ever leaves the machine. This removes cloud involvement from the trust model and places responsibility on the application to enforce deterministic execution.

It also dictates the architectural split. Planning happens in TS. Validation and mutation happen in Rust. This eliminates cases where UI logic could accidentally bypass safety guarantees.

Rust and TypeScript boundary

Communication occurs via a Tauri command. The UI sends the raw protocol to Rust using invoke(“apply_protocol”, { xmlInput }). Rust resolves paths, parses the protocol, executes the requested changes, and returns structured success and error data.

const { errors, success } = await invoke<{
  errors: ErrorReport[];
  success: SuccessReport[];
}>("apply_protocol", {
  xmlInput: planToApply,
});

On success, the UI may reformat the resulting files with Prettier, then rewrite them via Tauri’s filesystem API. This yields high-quality surface formatting without weakening the correctness boundary: Rust has already applied the authoritative content.

Applying changes

apply_file_change is the core operation. It resolves the target path, then dispatches based on action:

pub fn apply_file_change(file_change: &FileChange) -> Result<()> {
    let result = (|| -> Result<()> {
        debug!("apply_file_change - Action: {:?}", file_change.action);
        debug!("apply_file_change - Path: {:?}", file_change.path);

        let resolved_path = resolve_file_path(&file_change.path)?;

        match file_change.action {
            Action::Modify => { /* read, patch, write */ }
            Action::Rewrite => { /* full overwrite */ }
            Action::Create => { /* write only if not exists */ }
            Action::Delete => { /* remove only if exists */ }
        }
        Ok(())
    })();
    if let Err(ref e) = result {
        sentry::capture_error(e.root_cause());
    }
    result
}

modify is the most nuanced. It uses a two-stage patching strategy: 1. Literal match 2. Whitespace-tolerant regex match

fn apply_change_to_content(content: &str, find_text: &str, replacement: &str) -> Result<String> {
    if let Some(start) = content.find(find_text) {
        let end = start + find_text.len();
        let mut new_content = content.to_string();
        new_content.replace_range(start..end, replacement);
        return Ok(new_content);
    }
    let tokens: Vec<String> = find_text
        .split_whitespace()
        .map(|t| regex_escape(t))
        .collect();
    let pattern = format!("(?s)\\s*{}\\s*", tokens.join(r"\s+"));
    let re = Regex::new(&pattern)?;
    if let Some(mat) = re.find(content) {
        let mut new_content = content.to_string();
        new_content.replace_range(mat.start()..mat.end(), replacement);
        return Ok(new_content);
    }
    Err(anyhow!("Search block not found"))
}

If neither match succeeds, nothing changes. The failure is reported so the user can refine instructions.

UI

The UI shows the proposed plan directly. It does not hide the protocol. Users choose which change blocks to apply, and the result is filtered in TS before being sent to Rust. Monaco provides code display; MUI and Framer build the surrounding interface. The workflow is meant to remain explicit and predictable.

Challenges and direction

The hardest problems involve repeated iteration and partial acceptance: plans that require multiple refinement loops, edge-case merges, and safe state reset. Because everything is local, o11n cannot offload complexity to a remote log stream. Errors and reconciliation must be handled in place.

The next major steps include version-control awareness and structured refactoring support so the protocol can represent higher-order transformations rather than just text substitution.

Closing

o11n treats a repository as a structured local object. TypeScript constructs the prompt; the model returns text; Rust validates and applies. Tauri defines the boundary so only Rust can mutate the project. The workflow keeps control with the user and preserves privacy by never sending code away.

If code is the medium, o11n makes the path from intention to applied change shorter and safer.