Your First Pack

Your First Pack

In this tutorial, you'll create a fully functional Intelligence Pack in about 10 minutes. By the end, you'll have a pack with multiple commands that you can run locally.

What We're Building

A simple "notes" pack with three commands: - save-note – Save a note with a title - get-note – Retrieve a note by title - list-notes – List all saved notes

Step 1: Create the Pack

Use the CLI to scaffold a new pack:

huitzo pack new notes-pack
cd notes-pack

This creates the following structure:

notes-pack/
├── huitzo.yaml          # Single source of truth for pack config
├── pyproject.toml       # Auto-generated from huitzo.yaml
├── README.md
├── src/
│   └── notes_pack/
│       ├── __init__.py
│       └── commands/
│           ├── __init__.py
│           └── example.py
└── tests/
    ├── __init__.py
    └── test_example.py

Step 2: Define Your Commands

Replace the contents of src/notes_pack/commands/example.py:

Note: The code below is an illustrative example showing the command pattern. It demonstrates the structure and SDK usage — adapt it to your own pack's domain.

# pseudocode — illustrative command pattern (adapt to your domain)
"""Notes pack commands."""

from huitzo_sdk import command, Context
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime


class SaveNoteArgs(BaseModel):
    """Arguments for saving a note."""
    title: str = Field(description="Note title (used as identifier)")
    content: str = Field(description="Note content")


class GetNoteArgs(BaseModel):
    """Arguments for retrieving a note."""
    title: str = Field(description="Title of the note to retrieve")


class ListNotesArgs(BaseModel):
    """Arguments for listing notes."""
    limit: int = Field(default=10, ge=1, le=100, description="Max notes to return")


@command("save-note", namespace="notes")
async def save_note(args: SaveNoteArgs, ctx: Context) -> dict:
    """Save a new note or update an existing one.

    Notes are stored per-user and persist across sessions.
    """
    note_data = {
        "title": args.title,
        "content": args.content,
        "created_at": datetime.utcnow().isoformat(),
        "updated_at": datetime.utcnow().isoformat(),
    }

    # Check if note exists (for updated_at logic)
    existing = await ctx.storage.get(f"note:{args.title}")
    if existing:
        note_data["created_at"] = existing["created_at"]

    # Save the note
    await ctx.storage.save(f"note:{args.title}", note_data)

    ctx.log.info(f"Saved note: {args.title}")

    return {
        "status": "saved",
        "title": args.title,
        "is_update": existing is not None
    }


@command("get-note", namespace="notes")
async def get_note(args: GetNoteArgs, ctx: Context) -> dict:
    """Retrieve a note by its title.

    Returns the note content and metadata.
    """
    note = await ctx.storage.get(f"note:{args.title}")

    if note is None:
        return {
            "found": False,
            "title": args.title,
            "error": "Note not found"
        }

    return {
        "found": True,
        **note
    }


@command("list-notes", namespace="notes")
async def list_notes(args: ListNotesArgs, ctx: Context) -> dict:
    """List all saved notes.

    Returns note titles and creation dates.
    """
    # Get all keys with the "note:" prefix
    keys = await ctx.storage.list(prefix="note:")

    notes = []
    for key in keys[:args.limit]:
        note = await ctx.storage.get(key)
        if note:
            notes.append({
                "title": note["title"],
                "created_at": note["created_at"],
                "preview": note["content"][:50] + "..." if len(note["content"]) > 50 else note["content"]
            })

    return {
        "count": len(notes),
        "notes": notes
    }

Step 3: Update huitzo.yaml

Update huitzo.yaml to register your commands. This is the single source of truth for all pack configuration — pyproject.toml is auto-generated from it.

# pseudocode — huitzo.yaml configuration
pack:
  name: notes-pack
  namespace: notes
  version: 1.0.0
  description: "Save, retrieve, and list personal notes"
  visibility: private

commands:
  - name: save-note
    description: "Save a new note"
    entry_point: "notes_pack.commands.example:save_note"
  - name: get-note
    description: "Retrieve a note by title"
    entry_point: "notes_pack.commands.example:get_note"
  - name: list-notes
    description: "List all saved notes"
    entry_point: "notes_pack.commands.example:list_notes"

Note: You never need to edit pyproject.toml directly. It is regenerated automatically when you run huitzo pack dev or huitzo pack build. You can also run huitzo pack sync to regenerate it manually.

Step 4: Start Development Session

Start a development session to test your pack:

huitzo pack dev

This starts a local proxy that connects to Huitzo's cloud sandbox where your pack code executes. You should see:

🔐 Authenticating... OK ([email protected])
📦 Packaging pack... 3 commands found
☁️  Uploading to sandbox... done

🚀 Development session started

   Proxy:    http://localhost:8080
   API docs: http://localhost:8080/docs

   Commands:
   - POST /api/v1/commands/notes/save-note
   - POST /api/v1/commands/notes/get-note
   - POST /api/v1/commands/notes/list-notes

📊 Quota: 847/1000 executions this month
⌨️  Press Ctrl+C to stop

[12:34:56] Watching for file changes...

How it works: The proxy runs on your machine (localhost:8080), but your pack code executes in Huitzo's cloud sandbox. This means you don't need PostgreSQL, Redis, or any infrastructure locally—the sandbox handles everything.

Step 5: Test Your Commands

Using curl

# Save a note
curl -X POST http://localhost:8080/api/v1/commands/notes/save-note \
  -H "Content-Type: application/json" \
  -d '{"title": "My First Note", "content": "Hello, Huitzo!"}'

# Response:
# {"status": "saved", "title": "My First Note", "is_update": false}

# Get the note
curl -X POST http://localhost:8080/api/v1/commands/notes/get-note \
  -H "Content-Type: application/json" \
  -d '{"title": "My First Note"}'

# Response:
# {"found": true, "title": "My First Note", "content": "Hello, Huitzo!", ...}

# List all notes
curl -X POST http://localhost:8080/api/v1/commands/notes/list-notes \
  -H "Content-Type: application/json" \
  -d '{}'

# Response:
# {"count": 1, "notes": [{"title": "My First Note", ...}]}

Using the API Docs

Open http://localhost:8080/docs in your browser to see the interactive API documentation for your pack's commands.

Huitzo Documentation

Your dev session also starts a local documentation server with the full Huitzo docs:

  • Web UI: http://localhost:8124
  • MCP endpoint: http://localhost:8124/mcp (for AI coding assistants)

This gives you instant access to SDK reference, examples, and guides while developing.

Step 6: Write Tests

Replace tests/test_example.py:

"""Tests for notes pack."""

import pytest
from notes_pack.commands.example import SaveNoteArgs, GetNoteArgs, ListNotesArgs


class TestSaveNoteArgs:
    """Test SaveNoteArgs validation."""

    def test_valid_args(self):
        args = SaveNoteArgs(title="Test", content="Content")
        assert args.title == "Test"
        assert args.content == "Content"

    def test_empty_title_fails(self):
        with pytest.raises(ValueError):
            SaveNoteArgs(title="", content="Content")


class TestGetNoteArgs:
    """Test GetNoteArgs validation."""

    def test_valid_args(self):
        args = GetNoteArgs(title="Test")
        assert args.title == "Test"


class TestListNotesArgs:
    """Test ListNotesArgs validation."""

    def test_default_limit(self):
        args = ListNotesArgs()
        assert args.limit == 10

    def test_custom_limit(self):
        args = ListNotesArgs(limit=50)
        assert args.limit == 50

    def test_limit_bounds(self):
        with pytest.raises(ValueError):
            ListNotesArgs(limit=0)
        with pytest.raises(ValueError):
            ListNotesArgs(limit=101)

Run tests:

huitzo pack test
# Or: pytest tests/

Step 7: Validate Your Pack

Before publishing, validate your pack:

huitzo pack validate

# Output:
# ✅ Manifest valid
# ✅ 3 commands found
# ✅ All commands have valid schemas
# ✅ Tests passing
# ✅ Ready to build!

Step 8: Build and Publish

# Build the distribution
huitzo pack build

# Publish to your Huitzo instance (publishes under your organization scope)
huitzo pack publish

After publishing, your commands will be available under your organization's scoped namespace:

@yourorg/notes-pack/save-note
@yourorg/notes-pack/get-note
@yourorg/notes-pack/list-notes

See Namespaces for more on scoped namespace resolution and WebCLI navigation.

What You've Learned

In this tutorial, you:

  1. ✅ Scaffolded a new pack with huitzo pack new
  2. ✅ Created commands with Pydantic validation
  3. ✅ Used ctx.storage for data persistence
  4. ✅ Used ctx.log for structured logging
  5. ✅ Started a cloud-connected development session
  6. ✅ Tested commands via the local proxy
  7. ✅ Wrote unit tests
  8. ✅ Validated and built your pack

Next Steps

Add More Features

Try enhancing your notes pack:

  • Add a delete-note command
  • Add search functionality with search-notes
  • Add tags to notes for categorization
  • Send email notifications when notes are created

Learn More

Deploy

Complete Example

For reference, here's the full command pattern with all four commands:

# pseudocode — complete example showing all four commands together
"""Notes pack commands."""

from huitzo_sdk import command, Context
from pydantic import BaseModel, Field
from datetime import datetime


class SaveNoteArgs(BaseModel):
    title: str = Field(description="Note title")
    content: str = Field(description="Note content")


class GetNoteArgs(BaseModel):
    title: str = Field(description="Note title")


class DeleteNoteArgs(BaseModel):
    title: str = Field(description="Note title")


class ListNotesArgs(BaseModel):
    limit: int = Field(default=10, ge=1, le=100)


@command("save-note", namespace="notes")
async def save_note(args: SaveNoteArgs, ctx: Context) -> dict:
    """Save a note."""
    existing = await ctx.storage.get(f"note:{args.title}")
    note_data = {
        "title": args.title,
        "content": args.content,
        "created_at": existing["created_at"] if existing else datetime.utcnow().isoformat(),
        "updated_at": datetime.utcnow().isoformat(),
    }
    await ctx.storage.save(f"note:{args.title}", note_data)
    ctx.log.info(f"Saved note: {args.title}")
    return {"status": "saved", "title": args.title, "is_update": existing is not None}


@command("get-note", namespace="notes")
async def get_note(args: GetNoteArgs, ctx: Context) -> dict:
    """Get a note by title."""
    note = await ctx.storage.get(f"note:{args.title}")
    if note is None:
        return {"found": False, "title": args.title, "error": "Not found"}
    return {"found": True, **note}


@command("delete-note", namespace="notes")
async def delete_note(args: DeleteNoteArgs, ctx: Context) -> dict:
    """Delete a note."""
    exists = await ctx.storage.exists(f"note:{args.title}")
    if not exists:
        return {"deleted": False, "error": "Not found"}
    await ctx.storage.delete(f"note:{args.title}")
    ctx.log.info(f"Deleted note: {args.title}")
    return {"deleted": True, "title": args.title}


@command("list-notes", namespace="notes")
async def list_notes(args: ListNotesArgs, ctx: Context) -> dict:
    """List all notes."""
    keys = await ctx.storage.list(prefix="note:")
    notes = []
    for key in keys[:args.limit]:
        note = await ctx.storage.get(key)
        if note:
            notes.append({
                "title": note["title"],
                "created_at": note["created_at"],
                "preview": note["content"][:50] + ("..." if len(note["content"]) > 50 else "")
            })
    return {"count": len(notes), "notes": notes}