Back to Blog
Technical
December 12, 2024
9 min read

API Design Principles: Building APIs Developers Love

Why Iris uses OpenAI's API spec and what I learned about API design building local-first tools. Compatibility beats purity every time.

Noam Favier
Developer & Founder
API Design Principles: Building APIs Developers Love

API Design: Compatibility Beats Purity

When I built Iris, I had a choice: design a perfect, clean API from scratch, or copy OpenAI's format exactly. I copied. Here's why that was the right call and what it taught me about API design.

The OpenAI-Compatible Decision

Iris is a local AI assistant. Runs entirely on your machine. No cloud. No API keys. But it exposes this:

POST /v1/chat/completions
Authorization: Bearer local-only

{
  "model": "iris",
  "messages": [
    {"role": "user", "content": "What's the weather?"}
  ]
}

That's OpenAI's API. Not "inspired by" or "similar to"—it's the exact same format. Point your OpenAI SDK at localhost:8080 and it works. No changes. No adapter layer.

Why? Because **every tool already supports it**. VSCode extensions, CLI tools, web apps—anything built for OpenAI works with Iris immediately. That's not elegance, that's leverage.

What Actually Matters in API Design

Forget REST purity. Here's what makes APIs good or bad in practice:

1. Don't Make Me Read Docs

Good APIs are predictable. If you've seen one endpoint, you've seen them all. Sysmon-CLI's metrics API follows one pattern:

GET /metrics/cpu
GET /metrics/memory
GET /metrics/disk
GET /metrics/network

Same response shape. Same query params. Same error format. You learn it once.

Bad APIs surprise you. Different endpoints need different auth headers. Error formats change. Status codes are creative interpretations of HTTP.

2. Compatibility > Clever Design

The OpenAI API isn't perfect. The `messages` array gets repetitive. The `model` field is redundant when you're hitting localhost. But changing it means every integration needs custom code.

I wanted to add a `context_window` param directly in the request. Cleaner. More obvious. But then I'd need to maintain SDK forks, browser extensions, and CLI wrappers. Not worth it.

**Boring and compatible wins**. Every time.

3. Errors Should Tell You What to Fix

Here's a bad error:

{"error": "Invalid request"}

Here's what Zvezda returns when you mess up a Git remote URL:

{
  "error": {
    "code": "invalid_remote",
    "message": "Git remote URL must start with https:// or git@",
    "details": {
      "provided": "github.com/user/repo",
      "examples": [
        "https://github.com/user/repo.git",
        "git@github.com:user/repo.git"
      ]
    }
  }
}

You know exactly what's wrong. You know how to fix it. No Googling. No guessing.

4. Status Codes: Use the Boring Ones

You need maybe 8 status codes:

  • **200**: It worked
  • **201**: Created something
  • **400**: You sent bad data
  • **401**: Missing/invalid auth
  • **404**: Doesn't exist
  • **429**: Slow down
  • **500**: I broke something
  • **503**: Temporarily down
  • That's it. Don't get creative. I've seen APIs return 418 (I'm a teapot) for rate limiting. Very funny. Also useless—most HTTP clients don't handle it.

    Real Examples from My Tools

    Iris: When to Ignore REST

    Iris has a `/v1/models` endpoint that returns... one model. Always. It exists because OpenAI clients expect it, not because it's useful.

    GET /v1/models
    {
      "data": [
        {"id": "iris", "object": "model"}
      ]
    }

    Is this wasteful? Yes. Does it matter? No. Compatibility is the feature.

    Sysmon-CLI: Keep It Simple

    Sysmon-CLI exposes metrics over HTTP. I could've built a GraphQL API where you request exactly the fields you want. Or a sophisticated query language. Or subscriptions with SSE.

    Instead:

    GET /metrics/cpu?interval=1s
    {
      "timestamp": "2025-01-09T10:30:00Z",
      "usage_percent": 23.4,
      "cores": [12.1, 34.2, 18.9, 29.3]
    }

    JSON over HTTP. That's it. Works with `curl`. Works with `fetch()`. Works with every language's stdlib. No dependencies. No learning curve.

    Zvezda: Local-Only Auth

    Zvezda manages Git repos. Runs on localhost. Needs auth so random scripts can't mess with your repos. But it's just you.

    Authorization: Bearer zvezda-local-token

    That token? It's in `~/.config/zvezda/token`. Generated once. Never expires. Not JWT. Not OAuth. Just a random string that proves the request came from your machine.

    Overengineering would be: OAuth2 flows, token refresh, PKCE, scopes, the works. For an API that only accepts connections from localhost.

    What I Avoid

    **Custom HTTP verbs**. I've seen `LINK`, `UNLINK`, `MERGE`. Use POST. Everyone understands POST.

    **Nested REST resources beyond 2 levels**. `/users/123/repos/456/issues/789/comments/012` is unreadable. Flatten it: `/comments/012`.

    **Required versioning from day 1**. Iris started at `/v1` because OpenAI does. Sysmon-CLI doesn't version—it's not a public API, I can change it. Zvezda has no version. If I need breaking changes, I'll add `/v2` then.

    **Pagination for small datasets**. Zvezda's repo list never exceeds 1000 entries. No pagination. Just return the array. When it becomes a problem, I'll fix it. YAGNI applies to APIs too.

    The One Rule

    **Make it work with `curl` first.**

    If your API needs a custom client to be usable, you're doing it wrong. Every endpoint should have a `curl` example that just works.

    # Good
    curl http://localhost:8080/metrics/cpu
    
    # Bad (needs custom client because WebSocket handshake)
    wscat -c ws://localhost:8080/metrics

    REST is boring. JSON is boring. Bearer tokens are boring. Boring is good. Boring means it works everywhere with zero setup.

    When to Ignore All This

    If you're building for scale—billions of requests, thousands of services, multi-region deployments—ignore everything I said. Use gRPC. Use Protobuf. Version everything. Design for backwards compatibility from day 1.

    But if you're building tools for developers? Local-first apps? CLI utilities? Keep it simple. Copy what works. Compatibility beats purity.

    Iris didn't need to reinvent chat APIs. Sysmon-CLI didn't need GraphQL. Zvezda didn't need OAuth2.

    They needed to work. With `curl`. Right now. That's the entire design spec.

    API DesignRESTSoftware ArchitectureBest Practices