# Standard Contract for a Portal Skill in the AI Job Search Framework: Implementation Guide

> Implement the standard contract for a portal skill in the AI Job Search Framework. Learn about CLI commands, JSON schemas, error handling, and fetching for seamless `/scrape` workflow integration.

- Repository: [Mads Lorentzen/ai-job-search](https://github.com/MadsLorentzen/ai-job-search)
- Tags: architecture
- Published: 2026-09-01

---

**Every portal skill in the AI Job Search Framework must implement a strict contract defining CLI commands, JSON output schemas, error handling, and fetching behavior to ensure seamless integration with the `/scrape` workflow.**

The AI Job Search Framework, maintained in the `MadsLorentzen/ai-job-search` repository, treats job portals as pluggable skills. Each skill is a standalone CLI tool that conforms to the **portal-skill contract** documented in [`.claude/commands/add-portal.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/add-portal.md). This contract ensures that the higher-level orchestration can swap between `linkedin-search`, `jobbank-search`, or custom implementations without code changes.

## CLI Interface Requirements

### Required Sub-commands

Every portal skill must expose exactly two entry points as implemented in [`cli/src/cli.ts`](https://github.com/MadsLorentzen/ai-job-search/blob/main/cli/src/cli.ts):

- **`search`** – Executes a job search with filters and returns paginated results.
- **`detail <id|url>`** – Retrieves full metadata for a single job posting identified by ID or direct URL.

The dispatch logic typically uses `parseArgs` from Node's `util` module to route positional arguments to `runSearch` or `runDetail` handlers.

### Standard Flag Set

The contract mandates support for the following flags in all search commands:

- **`-q, --query <term>`** – Required search string.
- **`--jobage <days>`** – Filter results by maximum posting age.
- **`--page <n>`** – Pagination index (default: 1).
- **`--limit <n>`** – Results per page cap (default: 20).
- **`--format <json|table|plain>`** – Output serialization format (default: `json`).
- **`-l, --location <city>`** – Optional geographic filter (only if the portal supports it).

Missing values must not crash the CLI; instead, they should fall back to documented defaults.

## Data Contract and Output Schema

### JSON Output Structure

When `--format json` is specified (the default for programmatic use), skills must emit a single JSON object to **stdout** with this exact shape:

```json
{
  "meta": {
    "count": 50,
    "page": 1
  },
  "results": [
    {
      "id": "abc123",
      "title": "Senior Backend Engineer",
      "company": "Example Corp",
      "location": "Copenhagen",
      "date": "2024-01-15",
      "url": "https://example.com/jobs/abc123"
    }
  ]
}

```

The contract requires that missing values be set to `null` rather than omitted. This predictability allows the framework's aggregator in `/scrape` to deserialize outputs without defensive schema validation.

### Error Handling Standards

All errors must be written to **stderr** as compact JSON and exit with status code `1`. The error object must include `error` and `code` fields:

```json
{ "error": "Network timeout after 6 retries", "code": "FETCH_FAILED" }

```

Successful executions must never print error JSON to stdout, and non-zero exit codes must always accompany stderr output. This separation allows the parent workflow to distinguish between parseable empty results (`{"results": []}` on stdout) and genuine failures.

## Implementation Standards

### HTTP Fetching Behavior

Skills must implement resilient fetching as specified in [`.claude/commands/add-portal.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/add-portal.md):

1. **Polite User-Agent**: Identify as `Mozilla/5.0 (compatible; <portal>-cli/1.0)`.
2. **Exponential Back-off**: On HTTP `429` or `5xx` responses, retry with exponential back-off and jitter, capped at approximately 6 attempts.
3. **Graceful 404 Handling**: Return empty output (`""` or `null`) instead of throwing when a specific job detail page returns 404.

These rules are typically encapsulated in helper functions within [`cli/src/helpers.ts`](https://github.com/MadsLorentzen/ai-job-search/blob/main/cli/src/helpers.ts).

### HTML Parsing Resilience

The contract mandates that each job card be parsed independently. A malformed HTML card must not crash the entire batch. The reference implementation in the LinkedIn skill uses a `parseJobCards` function that wraps individual card parsing in try-catch blocks, logging warnings to stderr while continuing to process siblings.

### Dependency Constraints

Portal skills should aim for **zero runtime dependencies** beyond the Bun runtime. The standard stack is:

- `bun` for execution
- Global `fetch` for HTTP
- Native regex for HTML extraction

If a parsing library is absolutely necessary because regex cannot handle the markup complexity, the dependency must be documented in the skill's README with justification.

### Credential Management

For portals requiring API authentication, the skill must read the token exclusively from an environment variable named `<SERVICE>_API_TOKEN` (e.g., `LINKEDIN_API_TOKEN`). If the variable is unset, the skill must abort with the specific error code `MISSING_CREDENTIALS`. Tokens must never be hardcoded in source files or committed documentation.

## File Structure and Key Components

A compliant portal skill contains four critical files that implement the contract:

| File | Purpose | Contract Alignment |
|------|---------|-------------------|
| [`SKILL.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/SKILL.md) | Skill manifest declaring name, version, commands, flags, and examples. | Defines CLI interface. |
| [`cli/src/cli.ts`](https://github.com/MadsLorentzen/ai-job-search/blob/main/cli/src/cli.ts) | Entry point parsing arguments and dispatching to `runSearch` or `runDetail`. | Implements command routing and error emission. |
| [`cli/src/helpers.ts`](https://github.com/MadsLorentzen/ai-job-search/blob/main/cli/src/helpers.ts) | Shared utilities including `toResult` transformers, back-off fetch logic, and parsing helpers. | Enforces output shaping and fetching rules. |
| [`url-reference.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/url-reference.md) | Documentation of endpoints, query parameters, and portal-specific quirks. | Supports maintenance and debugging. |

The [`helpers.ts`](https://github.com/MadsLorentzen/ai-job-search/blob/main/helpers.ts) file typically exports two critical functions for contract compliance:

```typescript
// Normalizes raw portal data to the standard schema
export function toResult(raw: any): PortalResult {
  return {
    id: raw.id ?? null,
    title: raw.title ?? null,
    company: raw.company ?? null,
    location: raw.location ?? null,
    date: raw.date ?? null,
    url: raw.url ?? null,
  };
}

// Emits standardized errors to stderr and exits
export function fatal(message: string, code: string): never {
  console.error(JSON.stringify({ error: message, code }));
  process.exit(1);
}

```

## Example Implementation

### Skill Manifest (SKILL.md)

```markdown
---
name: example-search
version: 1.0.0
description: Search Example.com for jobs (English & local language)
context: fork
allowed-tools: Bash(bun run .agents/skills/example-search/cli/src/cli.ts *)
---

# Example.com Job Search

Search the Example.com job board.

## Commands

- `search` – Find jobs.
- `detail <id|url>` – Show a single posting.

## Flags

- `-q, --query <term>` – Search term (required)
- `-l, --location <city>` – Optional location filter
- `--page <n>` – Page number (default 1)
- `--limit <n>` – Max results per page (default 20)
- `--format <json|table|plain>` – Output format (default json)

## Example

```shell
bun run .agents/skills/example-search/cli/src/cli.ts search -q "backend" --limit 5 --format table

```

```

### Argument Parsing (cli/src/cli.ts)

```typescript
import { parseArgs } from "util";
import { runSearch, runDetail } from "./commands";

const args = parseArgs({
  options: {
    query: { type: "string", short: "q" },
    location: { type: "string", short: "l" },
    page: { type: "number", default: 1 },
    limit: { type: "number", default: 20 },
    format: { type: "string", default: "json" },
  },
  allowPositionals: true,
});

const [cmd, ...pos] = args._;

if (cmd === "search") {
  await runSearch(args);
} else if (cmd === "detail") {
  await runDetail(pos[0] ?? "", args);
} else {
  console.error(JSON.stringify({ error: "Invalid command", code: "BAD_COMMAND" }));
  process.exit(1);
}

```

## Summary

- **Two commands required**: Every skill must implement `search` and `detail <id|url>` sub-commands via the dispatcher in [`cli/src/cli.ts`](https://github.com/MadsLorentzen/ai-job-search/blob/main/cli/src/cli.ts).
- **Strict JSON schema**: Output must include `meta` and `results` arrays with `null` for missing fields, never omitted keys.
- **Stderr error contract**: Errors emit JSON to stderr with an exit code of `1`, while stdout remains parseable or empty.
- **Zero-dependency philosophy**: Use Bun's native `fetch` and regex; external parsers require README justification.
- **Environment-only credentials**: API tokens read from `<SERVICE>_API_TOKEN` variables with `MISSING_CREDENTIALS` error codes if absent.

## Frequently Asked Questions

### What happens if a portal skill omits the `--format` flag?

The skill fails contract compliance. The `/scrape` workflow relies on machine-readable JSON output, and while `table` or `plain` formats are optional display modes, `json` must be supported as the default behavior.

### Can I use Python or Node.js instead of Bun for the skill runtime?

According to the contract in [`.claude/commands/add-portal.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/add-portal.md), skills must use Bun as the runtime to ensure consistent performance and dependency management across the framework. Using alternative runtimes would break the zero-dependency guarantees and the standardized execution environment.

### How should a skill handle rate limiting from the job portal?

Implement exponential back-off with jitter when receiving HTTP `429` responses, retrying up to six times before emitting a `FETCH_FAILED` error to stderr. This protects both the portal's infrastructure and the reliability of the scraping workflow.

### Is it mandatory to implement the `detail` command if I only need search functionality?

Yes. The contract requires both `search` and `detail` commands even if the immediate use case only involves listing jobs. The `detail` command ensures that downstream enrichment pipelines can fetch full descriptions without re-implementing portal-specific logic in the orchestration layer.