# Fable MCP Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add API key auth to Fabled Assistant and build a standalone Python MCP server that gives Claude full CRUD access to notes, tasks, projects, milestones, semantic search, and chat. **Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. **Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance. **Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. **Testing convention:** Phase 1 tests run via `docker compose run --rm app pytest tests/ -v`. Phase 2 tests run locally with `cd fable-mcp && pytest -v`. All Phase 1 tests are unit tests of pure functions — mock DB calls with `unittest.mock.AsyncMock`; never connect to a real database in tests. **Important discovery:** `Conversation.conversation_type` column already exists in the model (`models/conversation.py:23`) and `list_conversations` already filters by it (`services/chat.py:49`). No migration needed for conversation types — only service/route wiring. --- ## Phase 1: Fable API Key Feature ### File Map | Action | File | Purpose | |--------|------|---------| | Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | | Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model | | Modify | `src/scribe/models/__init__.py` | Export `ApiKey` | | Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions | | Modify | `src/scribe/auth.py` | Add bearer token check before session fallback | | Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | | Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` | | Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | | Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | | Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body | | Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint | | Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | | Create | `tests/test_api_keys.py` | Unit tests for service + auth | | Create | `tests/test_search_route.py` | Unit test for search endpoint | --- ### Task 1: ApiKey model + migration **Files:** - Create: `src/scribe/models/api_key.py` - Create: `alembic/versions/0027_add_api_keys.py` - Modify: `src/scribe/models/__init__.py` - [ ] **Step 1: Write the model** Create `src/scribe/models/api_key.py`: ```python from datetime import datetime, timezone from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import CreatedAtMixin class ApiKey(Base, CreatedAtMixin): __tablename__ = "api_keys" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) name: Mapped[str] = mapped_column(Text, nullable=False) key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) key_prefix: Mapped[str] = mapped_column(Text, nullable=False) scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write" last_used_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) revoked_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) __table_args__ = ( Index("ix_api_keys_user_id", "user_id"), Index("ix_api_keys_key_hash", "key_hash"), ) def to_dict(self) -> dict: return { "id": self.id, "name": self.name, "key_prefix": self.key_prefix, "scope": self.scope, "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, "created_at": self.created_at.isoformat(), "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, } ``` - [ ] **Step 2: Export from models __init__** In `src/scribe/models/__init__.py`, add after the last import line: ```python from scribe.models.api_key import ApiKey # noqa: E402, F401 ``` - [ ] **Step 3: Write the migration** Create `alembic/versions/0027_add_api_keys.py`: ```python """add api_keys table Revision ID: 0027 Revises: 0026 Create Date: 2026-03-23 """ from alembic import op import sqlalchemy as sa revision = "0027" down_revision = "0026" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "api_keys", sa.Column("id", sa.Integer(), primary_key=True), sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("name", sa.Text(), nullable=False), sa.Column("key_hash", sa.Text(), nullable=False, unique=True), sa.Column("key_prefix", sa.Text(), nullable=False), sa.Column("scope", sa.Text(), nullable=False), sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), ) op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"]) def downgrade() -> None: op.drop_table("api_keys") ``` - [ ] **Step 4: Verify migration runs** ```bash docker compose run --rm app alembic upgrade head ``` Expected: `Running upgrade 0026 -> 0027, add api_keys table` - [ ] **Step 5: Commit** ```bash git add src/scribe/models/api_key.py \ src/scribe/models/__init__.py \ alembic/versions/0027_add_api_keys.py git commit -m "feat: add ApiKey model and migration 0027" ``` --- ### Task 2: ApiKey service **Files:** - Create: `src/scribe/services/api_keys.py` - Create: `tests/test_api_keys.py` (service tests) - [ ] **Step 1: Write the failing tests** Create `tests/test_api_keys.py`: ```python """Unit tests for api_keys service — pure logic, DB calls mocked.""" import hashlib from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from scribe.services.api_keys import ( _hash_key, generate_key, create_api_key, list_api_keys, revoke_api_key, lookup_key, ) def test_generate_key_format(): key = generate_key() assert key.startswith("fmcp_") assert len(key) > 12 # prefix + at least some random chars def test_generate_key_uniqueness(): keys = {generate_key() for _ in range(100)} assert len(keys) == 100 # no duplicates def test_hash_key_is_sha256(): key = "fmcp_testkey" h = _hash_key(key) expected = hashlib.sha256(key.encode()).hexdigest() assert h == expected def test_generate_key_prefix(): key = "fmcp_abcdefghijklmnop" # prefix is first 12 chars of the full key from scribe.services.api_keys import _key_prefix assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars @pytest.mark.asyncio async def test_create_api_key_returns_full_key(): mock_key_obj = MagicMock() mock_key_obj.id = 1 mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} with patch("scribe.services.api_keys.async_session") as mock_session_ctx: mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.add = MagicMock() mock_session.commit = AsyncMock() mock_session_ctx.return_value = mock_session full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read") assert full_key.startswith("fmcp_") assert isinstance(key_dict, dict) @pytest.mark.asyncio async def test_lookup_key_returns_none_for_unknown(): with patch("scribe.services.api_keys.async_session") as mock_session_ctx: mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) mock_result = MagicMock() mock_result.scalars.return_value.first.return_value = None mock_session.execute = AsyncMock(return_value=mock_result) mock_session_ctx.return_value = mock_session result = await lookup_key("fmcp_doesnotexist") assert result is None ``` - [ ] **Step 2: Run tests to verify they fail** ```bash docker compose run --rm app pytest tests/test_api_keys.py -v ``` Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet) - [ ] **Step 3: Write the service** Create `src/scribe/services/api_keys.py`: ```python import hashlib import secrets from datetime import datetime, timezone from sqlalchemy import select from scribe.models import async_session from scribe.models.api_key import ApiKey def generate_key() -> str: """Generate a new full API key. Never stored — caller must hash it.""" return "fmcp_" + secrets.token_urlsafe(32) def _hash_key(key: str) -> str: return hashlib.sha256(key.encode()).hexdigest() def _key_prefix(key: str) -> str: """Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg').""" return key[:12] async def create_api_key( user_id: int, name: str, scope: str ) -> tuple[str, dict]: """Create a new API key. Returns (full_key, key_dict). full_key is never stored.""" if scope not in ("read", "write"): raise ValueError("scope must be 'read' or 'write'") full_key = generate_key() key = ApiKey( user_id=user_id, name=name, key_hash=_hash_key(full_key), key_prefix=_key_prefix(full_key), scope=scope, ) async with async_session() as session: session.add(key) await session.commit() await session.refresh(key) return full_key, key.to_dict() async def list_api_keys(user_id: int) -> list[dict]: """List all non-revoked API keys for the user.""" async with async_session() as session: result = await session.execute( select(ApiKey) .where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None)) .order_by(ApiKey.created_at.desc()) ) return [k.to_dict() for k in result.scalars().all()] async def revoke_api_key(user_id: int, key_id: int) -> bool: """Soft-delete a key by setting revoked_at. Returns True if found.""" async with async_session() as session: result = await session.execute( select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id) ) key = result.scalars().first() if key is None: return False key.revoked_at = datetime.now(timezone.utc) await session.commit() return True async def lookup_key(raw_key: str) -> ApiKey | None: """Look up a non-revoked ApiKey by raw token value. Updates last_used_at.""" key_hash = _hash_key(raw_key) async with async_session() as session: result = await session.execute( select(ApiKey).where( ApiKey.key_hash == key_hash, ApiKey.revoked_at.is_(None), ) ) key = result.scalars().first() if key is None: return None key.last_used_at = datetime.now(timezone.utc) await session.commit() await session.refresh(key) return key ``` - [ ] **Step 4: Run tests to verify they pass** ```bash docker compose run --rm app pytest tests/test_api_keys.py -v ``` Expected: all tests pass - [ ] **Step 5: Commit** ```bash git add src/scribe/services/api_keys.py tests/test_api_keys.py git commit -m "feat: add ApiKey service with create/list/revoke/lookup" ``` --- ### Task 3: Auth middleware — bearer token support **Files:** - Modify: `src/scribe/auth.py` - Modify: `tests/test_api_keys.py` (add auth middleware tests) - [ ] **Step 1: Add auth middleware tests** Append to `tests/test_api_keys.py`: ```python def test_hash_key_deterministic(): """Same input always produces same hash (needed for lookup).""" key = "fmcp_some_test_key_value" assert _hash_key(key) == _hash_key(key) def test_scope_validation(): with pytest.raises(ValueError, match="scope must be"): import asyncio asyncio.run(create_api_key(1, "bad", "superadmin")) # --- Auth middleware tests --- @pytest.mark.asyncio async def test_bearer_token_path_sets_g_user(monkeypatch): """Valid bearer token authenticates and sets g.user and g.api_key.""" from unittest.mock import AsyncMock, MagicMock from scribe.auth import _check_auth # Mock ApiKey object fake_key = MagicMock() fake_key.user_id = 7 fake_key.scope = "write" # Mock User object fake_user = MagicMock() fake_user.role = "user" monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)) monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)) called_with_user = {} async def view_func(): from quart import g called_with_user["user"] = g.user called_with_user["api_key"] = g.api_key from quart import jsonify return jsonify({"ok": True}) from quart import Quart app = Quart(__name__) @app.route("/test", methods=["GET"]) @_check_auth async def protected(): return await view_func() async with app.test_request_context("/test", method="GET", headers={"Authorization": "Bearer fmcp_valid"}): # Just verify _check_auth calls lookup_key with the right token import scribe.auth as auth_module auth_module.lookup_key.assert_called_with # callable @pytest.mark.asyncio async def test_read_only_key_blocked_on_post(): """Read-only API key returns 403 on non-GET requests.""" from unittest.mock import AsyncMock, MagicMock from scribe.auth import _check_auth from quart import Quart fake_key = MagicMock() fake_key.user_id = 7 fake_key.scope = "read" fake_user = MagicMock() fake_user.role = "user" app = Quart(__name__) with ( app.test_request_context("/test", method="POST", headers={"Authorization": "Bearer fmcp_readonly"}), ): from unittest.mock import patch with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \ patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)): async def dummy(): return "ok" wrapped = _check_auth(dummy) from quart import current_app async with app.app_context(): resp = await wrapped() # Should return 403 for read-only key on POST assert resp[1] == 403 ``` - [ ] **Step 2: Run to verify pass** (these are pure logic tests) ```bash docker compose run --rm app pytest tests/test_api_keys.py -v ``` - [ ] **Step 3: Update auth.py** Replace `src/scribe/auth.py` with: ```python import functools from quart import g, jsonify, request, session from scribe.services.auth import get_user_by_id from scribe.services.api_keys import lookup_key def _check_auth(f, required_role: str | None = None): @functools.wraps(f) async def decorated(*args, **kwargs): # --- Bearer token path --- auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): raw_key = auth_header[len("Bearer "):] api_key = await lookup_key(raw_key) if api_key is None: return jsonify({"error": "Invalid or revoked API key"}), 401 user = await get_user_by_id(api_key.user_id) if not user: return jsonify({"error": "User not found"}), 401 # Scope enforcement: read-only keys cannot mutate if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"): return jsonify({"error": "Read-only key cannot perform write operations"}), 403 # Role check (admin_required routes) if required_role and user.role != required_role: return jsonify({"error": "Admin access required"}), 403 g.user = user g.api_key = api_key return await f(*args, **kwargs) # --- Session path (unchanged) --- user_id = session.get("user_id") if not user_id: return jsonify({"error": "Authentication required"}), 401 user = await get_user_by_id(user_id) if not user: session.clear() return jsonify({"error": "Authentication required"}), 401 if session.get("session_version") != user.session_version: session.clear() return jsonify({"error": "Session expired. Please log in again."}), 401 if required_role and user.role != required_role: return jsonify({"error": "Admin access required"}), 403 g.user = user return await f(*args, **kwargs) return decorated def login_required(f): return _check_auth(f) def admin_required(f): return _check_auth(f, required_role="admin") def get_current_user_id() -> int: return g.user.id ``` - [ ] **Step 4: Run the full test suite to check for regressions** ```bash docker compose run --rm app pytest tests/ -v ``` Expected: all existing tests still pass - [ ] **Step 5: Commit** ```bash git add src/scribe/auth.py tests/test_api_keys.py git commit -m "feat: add bearer token auth to _check_auth, falls back to session" ``` --- ### Task 4: API key routes + app registration **Files:** - Create: `src/scribe/routes/api_keys.py` - Modify: `src/scribe/app.py` - [ ] **Step 1: Write the routes** Create `src/scribe/routes/api_keys.py`: ```python from quart import Blueprint, jsonify, request from scribe.auth import login_required, get_current_user_id from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") @api_keys_bp.route("", methods=["GET"]) @login_required async def list_keys_route(): uid = get_current_user_id() keys = await list_api_keys(uid) return jsonify({"api_keys": keys}) @api_keys_bp.route("", methods=["POST"]) @login_required async def create_key_route(): uid = get_current_user_id() data = await request.get_json(force=True, silent=True) or {} name = (data.get("name") or "").strip() scope = (data.get("scope") or "").strip() if not name: return jsonify({"error": "name is required"}), 400 if scope not in ("read", "write"): return jsonify({"error": "scope must be 'read' or 'write'"}), 400 full_key, key_dict = await create_api_key(uid, name=name, scope=scope) # Return the full key ONCE — it is never retrievable again return jsonify({"key": full_key, "api_key": key_dict}), 201 @api_keys_bp.route("/", methods=["DELETE"]) @login_required async def revoke_key_route(key_id: int): uid = get_current_user_id() deleted = await revoke_api_key(uid, key_id) if not deleted: return jsonify({"error": "API key not found"}), 404 return "", 204 ``` - [ ] **Step 2: Register in app.py** In `src/scribe/app.py`, add the import alongside the other route imports: ```python from scribe.routes.api_keys import api_keys_bp ``` And add the registration line after `app.register_blueprint(users_bp)`: ```python app.register_blueprint(api_keys_bp) ``` - [ ] **Step 3: Smoke test the endpoints** ```bash docker compose up -d # Create a key curl -s -X POST http://localhost:8080/api/api-keys \ -H "Content-Type: application/json" \ -H "Cookie: " \ -d '{"name":"test","scope":"read"}' # Expected: {"key": "fmcp_...", "api_key": {...}} # Test bearer auth curl -s http://localhost:8080/api/auth/me \ -H "Authorization: Bearer fmcp_" # Expected: {"id": ..., "username": ...} ``` - [ ] **Step 4: Commit** ```bash git add src/scribe/routes/api_keys.py src/scribe/app.py git commit -m "feat: add API key CRUD routes and register blueprint" ``` --- ### Task 5: Conversation type wiring **Files:** - Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135) - Modify: `src/scribe/routes/chat.py` (lines 73-79) Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. - [ ] **Step 1: Update `create_conversation` service** In `src/scribe/services/chat.py`, change the function signature at line 17: ```python async def create_conversation( user_id: int, title: str = "", model: str = "", conversation_type: str = "chat" ) -> Conversation: async with async_session() as session: conv = Conversation( user_id=user_id, title=title, model=model, conversation_type=conversation_type, ) session.add(conv) await session.commit() result = await session.execute( select(Conversation) .options(selectinload(Conversation.messages)) .where(Conversation.id == conv.id) ) return result.scalars().first() ``` - [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** In `src/scribe/services/chat.py`, update the WHERE clause at line 130: ```python result = await session.execute( sa_delete(Conversation) .where( Conversation.user_id == user_id, Conversation.updated_at < cutoff, Conversation.conversation_type != "mcp", # preserve MCP audit trail ) .returning(Conversation.id) ) ``` - [ ] **Step 3: Update the POST route to accept conversation_type** In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73): ```python @chat_bp.route("/conversations", methods=["POST"]) @login_required async def create_conversation_route(): uid = get_current_user_id() data = await request.get_json(force=True, silent=True) or {} title = data.get("title", "") model = data.get("model", Config.OLLAMA_MODEL) conversation_type = data.get("conversation_type", "chat") # Only allow known types to prevent accidental misuse if conversation_type not in ("chat", "mcp"): conversation_type = "chat" conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) return jsonify(conv.to_dict()), 201 ``` - [ ] **Step 4: Run tests** ```bash docker compose run --rm app pytest tests/ -v ``` Expected: all tests pass - [ ] **Step 5: Commit** ```bash git add src/scribe/services/chat.py src/scribe/routes/chat.py git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" ``` --- ### Task 6: Semantic search endpoint **Files:** - Create: `src/scribe/routes/search.py` - Modify: `src/scribe/app.py` - Create: `tests/test_search_route.py` - [ ] **Step 1: Write the failing test** Create `tests/test_search_route.py`: ```python """Unit tests for the search route parameter mapping.""" import pytest from unittest.mock import patch, AsyncMock def test_content_type_mapping(): """Verify content_type string maps to correct is_task value.""" from scribe.routes.search import _content_type_to_is_task assert _content_type_to_is_task("note") is False assert _content_type_to_is_task("task") is True assert _content_type_to_is_task("all") is None assert _content_type_to_is_task("unknown") is None # default to all ``` - [ ] **Step 2: Run to verify it fails** ```bash docker compose run --rm app pytest tests/test_search_route.py -v ``` Expected: `ImportError` (module doesn't exist yet) - [ ] **Step 3: Write the route** Create `src/scribe/routes/search.py`: ```python from quart import Blueprint, jsonify, request from scribe.auth import login_required, get_current_user_id from scribe.services.embeddings import semantic_search_notes search_bp = Blueprint("search", __name__, url_prefix="/api/search") def _content_type_to_is_task(content_type: str) -> bool | None: """Map content_type query param to semantic_search_notes is_task arg.""" if content_type == "note": return False if content_type == "task": return True return None # "all" or unknown → no filter @search_bp.route("", methods=["GET"]) @login_required async def search_route(): uid = get_current_user_id() q = (request.args.get("q") or "").strip() if not q: return jsonify({"error": "q is required"}), 400 content_type = request.args.get("content_type", "all") limit = min(request.args.get("limit", 10, type=int), 50) is_task = _content_type_to_is_task(content_type) results = await semantic_search_notes( uid, q, limit=limit, is_task=is_task, threshold=0.3 ) return jsonify({ "results": [ { "id": note.id, "title": note.title, "body": note.body or "", "is_task": note.is_task, "tags": note.tags or [], "similarity": score, } for score, note in results # semantic_search_notes returns list[tuple[float, Note]] ], "total": len(results), }) ``` Note: check `services/embeddings.py` to confirm the return type of `semantic_search_notes` — it should return a list of `(Note, float)` tuples. If the signature differs, adjust the destructuring above accordingly. - [ ] **Step 4: Register in app.py** Add to imports in `src/scribe/app.py`: ```python from scribe.routes.search import search_bp ``` Add registration: ```python app.register_blueprint(search_bp) ``` - [ ] **Step 5: Run tests** ```bash docker compose run --rm app pytest tests/test_search_route.py tests/ -v ``` Expected: all tests pass - [ ] **Step 6: Commit** ```bash git add src/scribe/routes/search.py \ src/scribe/app.py \ tests/test_search_route.py git commit -m "feat: add GET /api/search semantic search endpoint" ``` --- ### Task 7: Settings UI — API Keys tab **Files:** - Modify: `frontend/src/views/SettingsView.vue` - [ ] **Step 1: Add "apikeys" to VALID_TABS** In `SettingsView.vue`, find the `VALID_TABS` array (search for `VALID_TABS`) and add `"apikeys"` to it. - [ ] **Step 2: Add sidebar tab button** In the settings sidebar tab list, add an "API Keys" tab button alongside the existing ones, following the same pattern as existing tabs (look for the `

Copy this key now — it will not be shown again.

{{ newKeyValue }}
Name Scope Prefix Last Used
{{ key.name }} {{ key.scope }} {{ key.key_prefix }}… {{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }} Sure?

No API keys yet.

``` - [ ] **Step 4: Add reactive state and methods to the script section** In the `