Spec-Driven AI Development: Building bgpd-simple with Spec Kit and Local AI Coding Agents

AI coding becomes much more useful when the model is not asked to invent both the requirements and the implementation at the same time.

That was one of the main lessons from working on bgpd-simple, a compact routing daemon written in C with BGP, OSPF, and MPLS/LDP support. The project is small enough to understand, but protocol work is unforgiving: a change can look perfectly reasonable in C and still be wrong because it violates one sentence in an RFC, serializes one field incorrectly, or breaks a state transition that only appears during reconnect or withdrawal.

For this project I used Spec Kit as the workflow layer, OpenCode as the coding agent, and local models served by llama.cpp. I started with Qwen 3.5 and GLM-4.7, then experimented with newer models, including Qwen 3.6 27B. The important part was not the particular model. The important part was moving as much engineering truth as possible out of the prompt and into versioned artifacts: specifications, RFC references, plans, task lists, tests, compliance matrices, and verification reports.

Spec-driven development loop

Why a routing daemon is a good SDD test case

A routing daemon is almost the opposite of a “vibe coding” application.

The external behavior is already specified. BGP is defined by documents such as RFC 4271, multiprotocol BGP by RFC 4760, OSPFv2 by RFC 2328, OSPFv3 by RFC 5340, and LDP by RFC 5036. The implementation may be original, but the observable protocol behavior cannot be arbitrary.

That makes the project a useful environment for spec-driven development because there are several independent sources of truth:

  • protocol RFCs;
  • the project configuration and CLI vocabulary;
  • internal architecture and invariants;
  • regression tests;
  • interoperability behavior;
  • explicit release/compliance gates.

The repository reflects this. Documentation is split into protocol domains and lifecycle-oriented material: BGP, OSPF, MPLS/LDP, shared architecture, troubleshooting, compliance matrices, and project reports. The release documentation also requires compliance claims to point to executable evidence rather than to prose alone.

In other words, the specification is not one large Markdown file. It is a graph of constraints.

Specification and evidence graph

The stack: Spec Kit, OpenCode or Claude Code, llama.cpp or LM Studio

The roles were deliberately separated. The primary configuration below is OpenCode + llama.cpp; Claude Code is an alternative coding agent, while LM Studio is an alternative local inference server. These are interchangeable components, not a claim that every combination was used for every bgpd-simple commit.

Spec Kit provided the structured development workflow. It turns an idea into artifacts such as a specification, technical plan, task list, and implementation/convergence steps. OpenCode acted as the coding driver: reading the repository, editing files, running builds and tests, and iterating on failures. llama.cpp supplied an OpenAI-compatible local HTTP endpoint. Qwen 3.5 and GLM-4.7 were my starting points, followed by experiments with newer models, including Qwen 3.6 27B.

Interchangeable agents and local inference servers

This distinction matters. Spec Kit is not the model and it is not the coding agent. It is the process layer. OpenCode or Claude Code drives repository edits and tests; llama.cpp or LM Studio serves a model locally. The model is replaceable as long as it can follow the process, reason over the repository, and use tools reliably.

Current Spec Kit releases list opencode as a supported integration and install OpenCode command files under .opencode/commands. Current documentation often shows skill names such as /speckit-specify, /speckit-plan, and /speckit-tasks. During this work I used the dotted command form commonly seen in earlier Spec Kit workflows — /speckit.specify, /speckit.plan, and /speckit.tasks. The exact slash-command spelling is version/integration dependent; the workflow boundary is the important part.

From Qwen 3.5 and GLM-4.7 to newer local models

I did not start this project with Qwen 3.6 27B. My early experiments used Qwen 3.5 and GLM-4.7. As newer model releases became available, I tried them with the same general setup: a local llama.cpp inference server exposed through OpenCode, with Spec Kit supplying the development workflow. Qwen 3.6 27B is one of the newer configurations discussed below, not the only model used over the lifetime of the project.

This model progression is useful context for the SDD approach. Rather than tie protocol requirements to a particular model or chat history, I could keep the RFC references, plans, test commands, and acceptance checks in the repository while changing the inference backend. I am not presenting controlled model benchmarks here: the important observation is the portability of the workflow, not an unmeasured claim that one model outperformed another.

The hardware behind the local experiments

My main setup was a server/workstation built around a Supermicro H11SSL-C motherboard, an AMD EPYC 7551 processor, and 256 GB RAM.

For an alternative setup, I used a Mougol X99 motherboard and later moved to a Machinist X99 D8 Max board, with two Intel Xeon E5-2680 processors and 64 GB RAM.

The GPUs available for the experiments were:

  • PNY GeForce RTX 5060 Ti OC — 16 GB VRAM;
  • Gainward GeForce RTX 5070 Ti Phoenix-S — 16 GB VRAM.

Local AI development hardware: main EPYC server, alternative dual-Xeon X99 machine, and GPUs

On the Machinist X99 D8 Max, PCIe/NUMA topology matters when distributing inference work across GPUs. PCIe slots may be attached to different CPU sockets, and communication between GPUs on opposite sockets can cross the CPU-to-CPU interconnect. In my setup, that became a bottleneck. For multi-GPU workloads with frequent cross-GPU transfers, I place both cards in PCIe slots attached to the same CPU whenever the board's slot wiring, physical clearance, and available PCIe lanes permit it. This avoids the extra inter-socket hop; it does not eliminate normal PCIe transfer costs. Check the actual mapping with lspci -tv and nvidia-smi topo -m rather than assuming that adjacent slots share a CPU. The best choice still depends on the model split and the transfer pattern.

Dual-socket PCIe topology: GPU placement across CPUs versus within one CPU's PCIe domain

The H11SSL-C's revision supports EPYC 7002 processors, so an EPYC 7502 upgrade would also be possible. That upgrade deserves a separate article; the experiments described here used the EPYC 7551. See Supermicro's official EPYC 7002 support FAQ for the revision requirement.

For models that do not fit entirely into a single 16 GB VRAM allocation, llama.cpp can use quantized GGUF weights and appropriate CPU/GPU offloading; the practical fit and speed depend on the quantization, available RAM, context length, and the actual GPU configuration. The hardware list above describes my test equipment, not a claim that both GPUs were always installed together or that every model ran fully in VRAM.

This is why a local OpenAI-compatible endpoint was so valuable: OpenCode's connection remained broadly the same even as I changed machines, models, and inference settings.

Adopting Spec Kit in an existing repository

bgpd-simple was not a greenfield “generate an app” exercise. For an existing repository, I prefer to initialize Spec Kit in place from a clean, reviewable Git state:

uv tool install specify-cli

cd bgpd-simple
git status

specify init --here --force --integration opencode

For a repository that already matters, --force should not mean “overwrite without looking.” It means the generated integration files are applied to an existing tree, so I want the pre-init state committed or stashed and I review the diff immediately afterward.

The resulting separation is useful:

.specify/              shared Spec Kit workflow data
.opencode/commands/    OpenCode integration commands
specs/                 feature specifications / plans / tasks
src/, include/         implementation
tests/                 executable behavior
docs/                  architecture, RFC compliance, reports

For protocol work I also keep project-level rules explicit. The repository's docs/meta/WO.md captures several of them: plan first for non-trivial work, minimize the impact of changes, do not accept temporary fixes when the root cause is knowable, and never mark a task done without verification. Those rules are exactly the kind of constraints I want an agent to inherit before it starts editing C.

A practical Spec Kit workflow

For a non-trivial protocol change, my workflow is roughly:

constitution / project rules
        ↓
specify the externally visible behavior
        ↓
clarify ambiguous protocol or compatibility points
        ↓
plan the implementation boundaries
        ↓
turn the plan into checkable tasks
        ↓
analyze the plan against the specification
        ↓
implement with OpenCode
        ↓
run targeted tests + full regression gate
        ↓
update compliance/evidence documents
        ↓
converge only when code, tests and docs agree

In command form, depending on the Spec Kit version:

/speckit.constitution   or /speckit-constitution
/speckit.specify        or /speckit-specify
/speckit.clarify        or /speckit-clarify
/speckit.plan           or /speckit-plan
/speckit.tasks          or /speckit-tasks
/speckit.analyze        or /speckit-analyze
/speckit.implement      or /speckit-implement
/speckit.converge       or /speckit-converge

I do not necessarily use every phase for a tiny bug. The value comes from making the amount of process proportional to the risk.

For example, changing a CLI string is not the same as changing BGP UPDATE encoding. For the second case I want the plan to mention the exact RFC section, affected parser/encoder path, malformed-input behavior, regression test, and release evidence update before the agent starts coding.

RFCs as executable requirements

The repository's compliance summary is more useful to an AI agent than a generic instruction such as “implement BGP correctly.” It maps protocol areas to RFCs, scope, gate status, and required evidence.

Some of the core references used by the project are:

DomainRFCPurpose in the project
BGPRFC 4271Core BGP-4 protocol
BGPRFC 4760Multiprotocol extensions
BGPRFC 6793Four-octet AS numbers
BGPRFC 4456Route reflection
BGPRFC 5065Confederations
BGPRFC 8654Extended messages
BGPRFC 9774AS_PATH segment handling updates
OSPFRFC 2328OSPFv2
OSPFRFC 5340OSPF for IPv6
OSPFRFC 3623Graceful restart
OSPFRFC 5187OSPFv3 graceful restart
OSPFRFC 5709HMAC-SHA authentication
OSPFRFC 4552OSPFv3 authentication/confidentiality
MPLS/LDPRFC 5036Label Distribution Protocol
MPLSRFC 3032MPLS label stack encoding
LDPRFC 6720LDP capability negotiation
LDPRFC 5918LDP IPv6 extensions
MPLS PWRFC 4447Pseudowire signaling
LDP/IGPRFC 7552LDP/IGP synchronization

The useful pattern is not “put RFC links in a README.” It is to turn them into acceptance criteria.

A specification for an encoder change might say:

Requirement:
- UPDATE encoding must follow RFC 4271 section 4.3.
- Empty-path behavior must be deterministic and interoperable.
- Malformed input must be rejected without corrupting peer/session state.
- Existing IPv4/IPv6 and AS4 tests must remain green.
- Add a focused regression reproducing the previously observed failure.
- Update the BGP compliance matrix if the supported behavior changes.

This is far harder for an agent to “interpret creatively” than “fix AS_PATH handling.”

What the commit history shows

The repository's own implementation ledger preserves a useful sequence of phase checkpoints from the March 7, 2026 stabilization work.

Commit and evidence timeline

The sequence is interesting because implementation, tests, and documentation are interleaved rather than treated as separate afterthoughts:

  • 8c64c0c hardened OSPF authentication writer fallback behavior and added regression coverage.
  • 3d6dc52 added an MPLS VPN data-plane regression for transport-label resolution upgrades.
  • f4ed696 fixed VPNv4 serialization so send-community extended survives without forcing AF activation.
  • 6b33ecd added VRF forwarding route-ownership transition coverage.
  • ede88bc / 38f5bb1 aligned LDP discovery CLI behavior with runtime loopback suppression and added a regression.
  • 894dccb fixed RD-safe VPN import cache deletion and reconnect-time update-source <ifname> resolution, while cleaning up AF-scoped parser behavior.
  • 225ca52 refreshed the implementation and project status documentation.
  • f04ef1b, 399d04a, and 3a3fca4 added parser/CLI regressions around VPNv4 shorthand and mixed-order VRF AF mutation.
  • b15e645 and 5493e3d consolidated the checkpoint/evidence documentation and removed duplicated readiness text.

The phase handoff recorded a full regression result of 152/152 passing tests at that checkpoint. The current repository README reports a different current test inventory, so I treat the numbers in the reports as historical evidence tied to that phase, not as an eternal project metric.

This is exactly the behavior I want from AI-assisted engineering: a code change should leave behind enough evidence that a later session — human or AI — can reconstruct why it exists.

The runtime architecture the agent had to preserve

The project is organized into focused C modules for infrastructure, platform integration, encoding, RIB, policy, routing, protocol state machines, OSPF, MPLS, configuration, and CLI.

A simplified data path looks like this:

bgpd-simple architecture

That architecture is another form of specification. If a task says “fix parsing under address-family vpnv4,” it should not casually mutate best-path or FIB code. If an LDP CLI discrepancy is really a presentation/runtime parity issue, the plan should say exactly where the source of truth lives.

A good agent plan therefore contains not only what to change, but also what must not move.

Running Qwen 3.6 27B locally with llama.cpp

For these experiments, model inference ran locally. That was useful for two reasons: repository context stayed under my control, and I could iterate without tying the workflow to a hosted model provider.

llama.cpp provides an OpenAI-compatible HTTP server, which makes it a convenient boundary between a coding agent and a local GGUF model.

A typical text-only coding setup looks like:

llama-server \
  -m /models/Qwen3.6-27B.gguf \
  --alias qwen3.6-27b \
  --host 127.0.0.1 \
  --port 8080 \
  --ctx-size 65536 \
  --n-gpu-layers 99

The exact GGUF quantization, context size, and GPU offload depend on the machine. I prefer to set an explicit --alias; otherwise the model ID exposed by /v1/models may be based on the model path, which is awkward to reference from OpenCode.

Before involving the coding agent, I verify the serving boundary directly:

curl http://127.0.0.1:8080/v1/models

and, if needed, a minimal chat request:

curl http://127.0.0.1:8080/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen3.6-27b",
    "messages": [
      {"role": "user", "content": "Return only: ready"}
    ]
  }'

This isolates model-serving problems from agent problems.

Connecting OpenCode directly to llama.cpp

OpenCode supports OpenAI-compatible custom providers, and its current provider documentation includes llama.cpp explicitly.

A minimal project opencode.json can look like this:

{
  "$schema": "https://opencode.ai/config.json",
  "model": "llama.cpp/qwen3.6-27b",
  "provider": {
    "llama.cpp": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "llama-server (local)",
      "options": {
        "baseURL": "http://127.0.0.1:8080/v1"
      },
      "models": {
        "qwen3.6-27b": {
          "name": "Qwen 3.6 27B (local)",
          "limit": {
            "context": 65536,
            "output": 16384
          }
        }
      }
    }
  }
}

Then I start OpenCode from the repository root:

opencode

The basic local connection is simple:

Local OpenCode to llama.cpp connection

The coding agent sees an OpenAI-compatible model provider. It does not need to know how Qwen is loaded, quantized, or placed across GPUs.

Alternative: OpenCode with LM Studio

LM Studio can replace llama-server without changing the Spec Kit specification, task list, or acceptance criteria. Load a model in LM Studio and enable its local server (default 127.0.0.1:1234). You can start the server with:

lms server start --port 1234
curl http://127.0.0.1:1234/v1/models

The model ID must match the ID returned by your server. For example, add an LM Studio provider to opencode.json and select it instead of the llama.cpp provider:

{
  "$schema": "https://opencode.ai/config.json",
  "model": "lmstudio/my-local-model",
  "provider": {
    "lmstudio": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "LM Studio (local)",
      "options": { "baseURL": "http://127.0.0.1:1234/v1" },
      "models": {
        "my-local-model": { "name": "Local model (replace ID)" }
      }
    }
  }
}

my-local-model is a placeholder; replace it in both locations with a real ID from /v1/models. This JSON illustrates an alternative configuration, not a second opencode.json to place alongside the original. LM Studio and llama.cpp expose similar OpenAI-style interfaces, but sampling, tool-call parsing, and context handling can differ by runtime and model.

Alternative coding agent: Claude Code

Spec Kit also supports Claude Code directly. In current Spec Kit versions, a fresh project can be initialized with --integration claude rather than --integration opencode:

specify init --here --force --integration claude
claude

For an existing Spec Kit project, avoid reinitializing unnecessarily. To install Claude alongside OpenCode (in Spec Kit versions supporting multiple integrations), use:

specify integration install claude

If your installed version reports an integration conflict, inspect specify integration list and the relevant switch/--force guidance before changing existing integration files. Claude Code's Spec Kit skills live under .claude/skills/, while OpenCode uses its own integration directory. The shared .specify/ artifacts remain the workflow's common source of truth.

Claude Code normally uses hosted Anthropic models. It can also act as a local-model client when the inference server supports the Anthropic Messages API. That does not turn Qwen into an Anthropic Claude model: Claude Code is the CLI agent, and the locally served model is still Qwen (or another loaded model).

Claude Code + LM Studio

LM Studio exposes POST /v1/messages as an Anthropic-compatible endpoint. With the model already loaded:

lms server start --port 1234
export ANTHROPIC_BASE_URL=http://127.0.0.1:1234
export ANTHROPIC_AUTH_TOKEN=lmstudio
claude --model YOUR_LM_STUDIO_MODEL_ID

Use the actual model identifier from LM Studio. The placeholder token is sufficient only when server authentication is disabled; if enabled, use a real LM Studio API token. Keep these environment variables scoped to the terminal/session using the local server so that subsequent Claude Code sessions are not accidentally redirected.

Claude Code + llama.cpp

Recent llama-server builds also expose Anthropic-compatible /v1/messages. Start a tool-capable model with a compatible chat template and enable --jinja for tool calls:

llama-server \
  -m /models/YOUR-MODEL.gguf \
  --alias local-coder \
  --host 127.0.0.1 --port 8080 \
  --ctx-size 65536 --jinja

export ANTHROPIC_BASE_URL=http://127.0.0.1:8080
export ANTHROPIC_AUTH_TOKEN=local
claude --model local-coder

The ANTHROPIC_BASE_URL deliberately has no /v1 suffix in these Claude Code commands; the client appends the Messages API route. As with LM Studio, tool-use behavior and streaming compatibility depend on the server build, template, context size, and model. Verify a small edit plus tool execution before starting a large RFC-driven task.

Agent and server connection matrix

The practical combinations are:

Coding agentllama.cppLM Studio
OpenCodeOpenAI-compatible :8080/v1OpenAI-compatible :1234/v1
Claude CodeAnthropic-compatible :8080/v1/messagesAnthropic-compatible :1234/v1/messages

In this architecture, the Spec Kit plan and the repository tests stay the same even if the agent CLI or serving engine changes. Switching the coding agent may still require its matching Spec Kit integration files; switching the inference server does not.

Other connection patterns

1. llama.cpp on another workstation

If the GPU machine is on a trusted private network, llama-server can listen on a non-loopback address:

llama-server \
  -m /models/Qwen3.6-27B.gguf \
  --alias qwen3.6-27b \
  --host 0.0.0.0 \
  --port 8080

and OpenCode can point to:

"baseURL": "http://gpu-workstation:8080/v1"

I would not expose this unauthenticated endpoint directly to the public Internet.

2. Remote GPU machine through an SSH tunnel

For a machine outside the local trusted network, an SSH tunnel keeps the OpenCode configuration unchanged:

ssh -L 8080:127.0.0.1:8080 user@gpu-host

llama-server still binds to 127.0.0.1 on the remote machine, while OpenCode still connects to http://127.0.0.1:8080/v1 locally.

Remote model through SSH tunnel

This is my preferred remote pattern because the inference endpoint itself remains loopback-only.

3. llama.cpp in Docker

The same boundary also works when the model server is containerized:

docker run --rm \
  --gpus all \
  -p 127.0.0.1:8080:8080 \
  -v /models:/models \
  ghcr.io/ggml-org/llama.cpp:server-cuda \
  -m /models/Qwen3.6-27B.gguf \
  --alias qwen3.6-27b \
  --host 0.0.0.0 \
  --port 8080 \
  --n-gpu-layers 99

OpenCode does not change. That is another advantage of keeping an HTTP compatibility boundary between the agent and inference runtime.

How I use the agent during implementation

A local model is still an agentic coding model, not an oracle. I get better results when the work is segmented around evidence.

For a protocol change I normally ask OpenCode to work through a bounded task such as:

Implement task T04 only.

Read the feature spec and plan first.
Use RFC 4271 and the project's BGP compliance document as authority.
Do not change unrelated CLI syntax.
Add the focused regression before considering the task complete.
Run the focused test, then the full relevant BGP gate.
Summarize changed files and verification evidence.

The useful part is that most of those instructions should already exist in project artifacts. The prompt becomes a selector and an execution boundary, not the sole memory of the project.

If the model starts making broad changes, that is usually a signal that the plan is too vague. If it repeatedly fails the same test, the project's workflow rules say to stop and re-plan rather than continue patching symptoms. This prevents the “agent keeps editing until the test happens to pass” failure mode.

Verification is part of implementation

bgpd-simple has dedicated compliance and soak tooling in addition to normal unit/regression tests. The project reports reference commands such as:

ctest --test-dir build --output-on-failure

and targeted protocol slices, plus scripts such as:

tools/run_bgp_mpls_compliance_gate.sh
tools/protocol_soak_evidence.sh
tools/mpls_ldp_soak_gate.sh

This is where spec-driven AI development becomes materially different from “AI generated the patch.”

The implementation is not done when the agent says it is done. It is done when:

  1. the requested behavior is present;
  2. the focused regression passes;
  3. the relevant protocol gate passes;
  4. unrelated regressions do not appear;
  5. compliance documentation still matches reality;
  6. the commit explains the behavioral boundary.

The repository's release rules capture the same idea: no in-scope FAIL items, explicit constraints for partial support, and compliance claims backed by both implementation references and executable evidence.

Where local models work well — and where they do not

In my use, Qwen 3.6 27B was useful for repository-level work, especially when the task had a narrow spec and strong tests. But a local model benefits even more than a frontier hosted model from reducing ambiguity.

The highest-value uses were:

  • tracing behavior across parser, protocol, RIB, CLI, and tests;
  • generating focused regression tests from a concrete failure;
  • implementing bounded changes after the plan was accepted;
  • keeping documentation and test evidence synchronized;
  • checking repetitive RFC-related conditions across multiple code paths.

The weaker pattern is giving the model a broad request such as “make OSPF fully compliant” and hoping it will discover scope, architecture, protocol semantics, test strategy, and release criteria by itself.

That is not a model-size problem. It is a requirements problem.

What Spec-Driven Development changed for me

The biggest change was not code generation speed. It was context durability.

A chat session is temporary. A specification, compliance matrix, regression test, and commit are durable. When the next task begins, OpenCode and the local model can re-read the same artifacts that a human maintainer would use.

The workflow also makes model replacement much less disruptive. I can use Qwen locally today, another local model tomorrow, or a hosted model for a difficult task. The process does not disappear with the model because the important state lives in the repository.

For protocol software, that is the direction I want AI development to move:

requirements as versioned artifacts, implementation as a constrained transformation, and verification as executable evidence.

The model is a powerful participant in that loop, but it is not the loop itself.

References

Project:

Tooling:

Tags: AI, Spec-Driven Development, Spec Kit, OpenCode, Claude Code, LM Studio, Qwen, llama.cpp, BGP, OSPF, MPLS, Routing, C, Local LLM

← Back to all posts