Automates RFP data collection and normalization into a canonical schema.

Install

mkdir -p .claude/skills/rfp-ingest && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/12616" && unzip -o skill.zip -d .claude/skills/rfp-ingest && rm skill.zip

Installs to .claude/skills/rfp-ingest

Activation

This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.

Ingest RFP opportunities from multiple data sources (SAM.gov, eMMA, RFPMart). Use when adding new data sources, modifying ingestion logic, or debugging data fetching issues.
173 chars✓ has a “when” trigger
Intermediate

Key capabilities

  • Ingest RFP data from SAM.gov, eMMA, RFPMart, and GovTribe
  • Normalize RFP data into a canonical schema
  • Deduplicate ingested RFP opportunities
  • Detect eligibility flags based on text patterns
  • Log ingestion status and record counts
  • Handle API errors with retries or alerts

How it works

The skill fetches RFP data from specified sources, normalizes it to a standard schema, and stores it in a database, handling deduplication and eligibility flagging.

Inputs & outputs

You give it
Optional number of days back for ingestion, e.g., 7
You get back
An object containing counts of ingested and updated records, and the source name

When to use rfp-ingest

  • Ingesting new RFP data
  • Adding a new RFP data source
  • Debugging data fetching logic

About this skill

RFP Ingestion Skill

Overview

This skill helps implement multi-source RFP data ingestion with canonical schema normalization and deduplication.

Supported Data Sources

SourcePriorityAPI TypeRate Limits
SAM.govP1REST API10 req/sec, 10k/day
Maryland eMMAP1Web scrapingRespectful crawling
RFPMartCurrentREST APIAs documented
GovTribeP2REST API (paid)Per subscription

Canonical Schema

All sources must normalize to this schema:

interface Opportunity {
  externalId: string;         // Source-specific ID
  source: "sam.gov" | "emma" | "rfpmart" | "govtribe";
  title: string;
  description: string;
  summary?: string;
  location: string;
  category: string;
  naicsCode?: string;
  setAside?: string;          // "Small Business", "8(a)", etc.
  postedDate: number;         // Unix timestamp
  expiryDate: number;         // Unix timestamp
  url: string;
  attachments?: Attachment[];
  eligibilityFlags?: string[];
  rawData: Record<string, unknown>;
  ingestedAt: number;
}

SAM.gov Integration

API Endpoint

https://api.sam.gov/opportunities/v2/search

Required Headers

{
  "Accept": "application/json",
  "X-Api-Key": process.env.SAM_GOV_API_KEY
}

Example Query

const params = new URLSearchParams({
  postedFrom: "2024-01-01",
  postedTo: "2024-12-31",
  limit: "100",
  offset: "0",
  ptype: "o",  // Opportunities only
});

Field Mapping

SAM.gov FieldCanonical Field
noticeIdexternalId
titletitle
descriptiondescription
postedDatepostedDate (parse to timestamp)
responseDeadLineexpiryDate (parse to timestamp)
placeOfPerformance.statelocation
naicsCodenaicsCode
setAsideDescriptionsetAside

Convex Implementation

Ingestion Action

// convex/ingestion.ts
import { action, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";

export const ingestFromSam = action({
  args: { daysBack: v.optional(v.number()) },
  handler: async (ctx, args) => {
    const apiKey = process.env.SAM_GOV_API_KEY;
    if (!apiKey) throw new Error("SAM_GOV_API_KEY not configured");

    const fromDate = new Date();
    fromDate.setDate(fromDate.getDate() - (args.daysBack ?? 7));

    const response = await fetch(
      `https://api.sam.gov/opportunities/v2/search?` +
      `api_key=${apiKey}&postedFrom=${fromDate.toISOString().split("T")[0]}&limit=100`,
      { headers: { Accept: "application/json" } }
    );

    if (!response.ok) {
      throw new Error(`SAM.gov API error: ${response.status}`);
    }

    const data = await response.json();
    let ingested = 0;
    let updated = 0;

    for (const opp of data.opportunitiesData ?? []) {
      const result = await ctx.runMutation(internal.rfps.upsert, {
        externalId: opp.noticeId,
        source: "sam.gov",
        title: opp.title ?? "Untitled",
        description: opp.description ?? "",
        location: opp.placeOfPerformance?.state ?? "USA",
        category: opp.naicsCode ?? "Unknown",
        postedDate: new Date(opp.postedDate).getTime(),
        expiryDate: new Date(opp.responseDeadLine).getTime(),
        url: `https://sam.gov/opp/${opp.noticeId}/view`,
        rawData: opp,
      });

      if (result.action === "inserted") ingested++;
      else updated++;
    }

    // Log ingestion
    await ctx.runMutation(internal.ingestion.logIngestion, {
      source: "sam.gov",
      status: "completed",
      recordsProcessed: data.opportunitiesData?.length ?? 0,
      recordsInserted: ingested,
      recordsUpdated: updated,
    });

    return { ingested, updated, source: "sam.gov" };
  },
});

Upsert Mutation

// convex/rfps.ts (internal mutation)
export const upsert = internalMutation({
  args: {
    externalId: v.string(),
    source: v.string(),
    title: v.string(),
    description: v.string(),
    location: v.string(),
    category: v.string(),
    postedDate: v.number(),
    expiryDate: v.number(),
    url: v.string(),
    rawData: v.optional(v.any()),
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query("rfps")
      .withIndex("by_external_id", (q) =>
        q.eq("externalId", args.externalId).eq("source", args.source)
      )
      .first();

    const now = Date.now();

    if (existing) {
      await ctx.db.patch(existing._id, { ...args, updatedAt: now });
      return { id: existing._id, action: "updated" as const };
    }

    const id = await ctx.db.insert("rfps", {
      ...args,
      ingestedAt: now,
      updatedAt: now,
    });
    return { id, action: "inserted" as const };
  },
});

Deduplication Strategy

  1. Exact match: externalId + source combination
  2. Title similarity: Fuzzy match titles within same deadline window
  3. URL canonicalization: Normalize URLs before comparison

Eligibility Pre-Filtering

Detect disqualifiers during ingestion:

const DISQUALIFIER_PATTERNS = [
  { pattern: /u\.?s\.?\s*(citizen|company|organization)\s*only/i, flag: "us-org-only" },
  { pattern: /onshore\s*(only|required)/i, flag: "onshore-required" },
  { pattern: /on-?site\s*(required|mandatory)/i, flag: "onsite-required" },
  { pattern: /security\s*clearance\s*required/i, flag: "clearance-required" },
  { pattern: /small\s*business\s*set[- ]aside/i, flag: "small-business-set-aside" },
];

function detectEligibilityFlags(text: string): string[] {
  return DISQUALIFIER_PATTERNS
    .filter(({ pattern }) => pattern.test(text))
    .map(({ flag }) => flag);
}

Scheduled Ingestion

// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

crons.interval(
  "ingest-sam-gov",
  { hours: 6 },
  internal.ingestion.ingestFromSam,
  { daysBack: 3 }
);

export default crons;

Error Handling

Error TypeAction
Rate limit (429)Exponential backoff, retry after delay
Auth error (401/403)Log error, alert admin
Server error (5xx)Retry up to 3 times
Parse errorLog raw data, skip record

Testing Approach

  1. Mock API responses for unit tests
  2. Use sandbox/test endpoints when available
  3. Validate schema transformation
  4. Test deduplication logic
  5. Verify eligibility flag detection

When not to use it

  • When the data source is not one of the supported platforms
  • When a custom schema is required that deviates from the canonical Opportunity interface
  • When real-time ingestion is needed without batch processing

Prerequisites

SAM_GOV_API_KEY

Limitations

  • Only supports SAM.gov, Maryland eMMA, RFPMart, and GovTribe as data sources
  • Relies on a predefined canonical schema for RFP opportunities
  • Rate limits and API errors are handled according to defined strategies

How it compares

This skill automates data fetching, normalization, and deduplication across multiple RFP sources, unlike manual collection and processing.

Compared to similar skills

rfp-ingest side by side with the closest alternatives in the catalog.

SkillInstallsUpdatedSafetyDifficulty
rfp-ingest (this skill)06moReviewIntermediate
apify-ultimate-scraper03moReviewIntermediate
web-scraper01moNo flagsIntermediate
apify-trend-analysis03moReviewIntermediate

Try saying

Example prompts that trigger this skill in your AI assistant.

Search skills

Search the agent skills registry