Multi-source RFP data ingestion

VerifiedSafe

Ingest RFP opportunities from multiple data sources (SAM.gov, eMMA, RFPMart) with canonical schema normalization and deduplication. Perfect for adding new sources, modifying ingestion logic, or debugging data fetching issues.

Sby Skills Guide Bot
Data & AIIntermediate
3/11/2026
Claude Code
#data-ingestion#csv-parsing#api-integration#schema-normalization#rfp

Recommended for

Automated summary

Implementation guide for ingesting RFP opportunities from several sources (SAM.gov, eMMA, RFPMart) and normalizing them into one canonical schema.

Strengths

  • Source table with priorities, API types and rate limits helps plan ingestion work
  • Defines an explicit canonical Opportunity interface every source must normalize to
  • Ships a quote-aware CSV parser plus the exact headerless CSV layout of RFPMart email alerts
  • Includes an ID-prefix-to-category map and the list of IT-relevant prefixes for filtering

Limitations

  • The pasted content is truncated: the SAM.gov query example cuts off mid-code
  • Tightly coupled to one project's file layout (convex/ingestion, components/admin), so it is not directly reusable
  • eMMA scraping and GovTribe integration are only named, with no procedure or implementation detail
When to use it

Use it when adding a new RFP data source, changing ingestion logic, or debugging a failed RFPMart CSV upload.

When not to use it

Skip it for general-purpose data ingestion guidance or if your project does not use the Convex stack it references.

Security analysis

Safe
Quality score84/100

This is a documentation/reference skill describing an RFP ingestion pipeline: schema definitions, field mappings, Convex action/mutation examples, deduplication, and error handling. Its only network activity is legitimate calls to public procurement APIs using an environment-provided key. The one real security nit is the example placing the API key in a URL query string rather than a header, which is a credential-leakage hygiene issue rather than malicious behavior. No destructive, exfiltrating, or obfuscated operations are present.

Findings
  • •Minor secret-handling anti-pattern in the SAM.gov example: the API key is interpolated into the request URL query string (`api_key=${apiKey}`) while the header-based `X-Api-Key` approach is documented earlier. Query-string keys can leak into proxy/CDN logs and error reports; recommend consistently using the header.
  • •Declared tools grant `Bash(npm:*)` and `Bash(npx:*)`, but the skill content is purely documentation and runs no scripts — no command execution is exercised, though npm/npx can install and run arbitrary packages if an agent improvises.
  • •No prompt-injection or instruction-override attempts found in the skill content; no obfuscated payloads, destructive commands, or outbound data exfiltration to third parties.
  • •The SAM.gov example places the API key in the query string (`api_key=${apiKey}`) rather than in the `X-Api-Key` header it documents as required; query-string secrets can leak via logs, proxies, and referrers.
  • •It includes a web-scraping data source (Maryland eMMA) with only 'respectful crawling' guidance — no rate-limit or terms-of-service handling described.
  • •Raw third-party payloads are persisted wholesale in `rawData`, which may retain sensitive or unbounded data; no size/sanitization limits are mentioned.
How this analysis is made

Examples

Add a new source
Add GovTribe as a P2 source in the ingestion pipeline and normalize its response to the canonical Opportunity schema, including deduplication against existing records.
Debug CSV column shift
The RFPMart CSV upload is putting titles in the wrong column when the title contains a comma inside quotes. Fix the parser in convex/ingestion/rfpmartCsv.ts and verify with a sample file.
Filter IT-only imports
Extend the RFPMart CSV import so the 'Only import IT-relevant RFPs' toggle also covers the TELCOM and CSE prefixes, and show skipped counts in the results summary.

name: rfp-ingest description: 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. allowed-tools: Read, Grep, Glob, Bash(npm:), Bash(npx:)

RFP Ingestion Skill

Overview

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

Supported Data Sources

| Source | Priority | API Type | Rate Limits | |--------|----------|----------|-------------| | SAM.gov | P1 | REST API | 10 req/sec, 10k/day | | Maryland eMMA | P1 | Web scraping | Respectful crawling | | RFPMart API | Current | REST API | As documented | | RFPMart CSV | Current | Manual upload | N/A | | GovTribe | P2 | REST API (paid) | Per subscription |

CSV Upload (RFPMart Email Alerts)

RFPMart sends periodic email alerts with CSV attachments. These can be manually uploaded through the Admin UI.

CSV Format (No Header Row)

| Column | Index | Content | Example | |--------|-------|---------|---------| | ID | 0 | RFP identifier | SW-82097 | | Country | 1 | Country code | USA | | State | 2 | State name | Idaho | | Title | 3 | Full title with location | SW-82097 - USA (Idaho) - Data Concealment... | | Deadline | 4 | Due date | March 25,2026 | | URL | 5 | RFPMart link | https://www.rfpmart.com/... |

ID Prefix → Category Mapping

const categoryMap: Record<string, string> = {
  SW: "Software Development",
  ITES: "IT Services",
  NET: "Networking",
  TELCOM: "Telecommunications",
  DRA: "Data & Research",
  CSE: "Security Services",
  HR: "Human Resources",
  PM: "Project Management",
  MRB: "Marketing & Branding",
  // ... other prefixes default to "Other"
};

IT-Relevant Prefixes

When filtering for IT-relevant RFPs only, these prefixes are included:

  • SW - Software Development
  • ITES - IT Services
  • NET - Networking
  • TELCOM - Telecommunications
  • DRA - Data & Research
  • CSE - Security Services

Key Files

| File | Purpose | |------|---------| | convex/ingestion/rfpmartCsv.ts | CSV parser and Convex action | | components/admin/CsvUpload.tsx | Drag-and-drop upload UI |

Usage

  1. Navigate to Admin → Data Sources tab
  2. Scroll to RFPMart CSV Upload section
  3. Drop a CSV file or click to browse
  4. Toggle "Only import IT-relevant RFPs" if desired
  5. View results summary (new/updated/skipped/errors)

Implementation Example

// Parsing CSV with quoted fields
function parseCSVLine(line: string): string[] {
  const fields: string[] = [];
  let current = "";
  let inQuotes = false;

  for (let i = 0; i < line.length; i++) {
    const char = line[i];
    if (char === '"') {
      if (inQuotes && line[i + 1] === '"') {
        current += '"';
        i++;
      } else {
        inQuotes = !inQuotes;
      }
    } else if (char === "," && !inQuotes) {
      fields.push(current);
      current = "";
    } else {
      current += char;
    }
  }
  fields.push(current);
  return fields;
}

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 Field | Canonical Field | |---------------|-----------------| | noticeId | externalId | | title | title | | description | description | | postedDate | postedDate (parse to timestamp) | | responseDeadLine | expiryDate (parse to timestamp) | | placeOfPerformance.state | location | | naicsCode | naicsCode | | setAsideDescription | setAside |

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 Type | Action | |------------|--------| | 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 error | Log 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

From the file to a first result

The download contains exactly the content displayed on this page. This copy may differ from the latest version in the source repository.

View the source and supporting files

This download contains SKILL.md only. Any scripts, references, templates and dependencies are not included. Get the required files from the source, preserving their directory structure, before continuing.

1. Download the file

Download SKILL.md

From the root of a test project, save the file as .skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7/SKILL.md, or use the command below. This folder is for inspecting the skill before installation.

Command for macOS/Linux or WSL: requires a POSIX shell and curl. On Windows without WSL, use the download button and create the folder manually.

mkdir -p '.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7' && curl --fail --show-error --location 'https://skills-guides.com/api/skills/multi-source-rfp-data-ingestion-mmlj2nx7/download' --output '.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7/SKILL.md.part' && mv '.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7/SKILL.md.part' '.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7/SKILL.md'

2. Check prerequisites

Read SKILL.md before using it. If it references scripts or external services, inspect them and prepare the required access in your test environment.

Tools declared by the author (check availability in your agent): Read, Grep, Glob, Bash(npm:*), Bash(npx:*)

No recognized prerequisites section in this file. This does not mean it is self-contained: libraries, tools and access requirements may be mentioned elsewhere in the content or source repository.

The platforms on this page are catalog indications. The paths below follow tool documentation; they do not prove this skill has been run in those tools. No minimum tool version is confirmed for this listing.

3. Load the skill

After inspection, copy the complete folder into your project using one of the commands below, then open a new tool session. The command stops if a folder with the same name already exists: compare the files before replacing it.

Claude Code
test -f '.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7/SKILL.md' && test -s '.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7/SKILL.md' && mkdir -p '.claude/skills' && test ! -e '.claude/skills/rfp-ingest' && cp -R '.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7' '.claude/skills/rfp-ingest'

OpenSkills

OpenSkills must already be installed. The local install/read procedure is checked with OpenSkills 1.5.0, which requires Node.js ≥ 20.6.0. Catalog slugs are not resolved automatically.

Official OpenSkills installation
openskills install './.skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7'

openskills read 'multi-source-rfp-data-ingestion-mmlj2nx7'

Reading should display the installed file content and its base directory. It does not run the skill or install its dependencies.

For an agent that reads AGENTS.md, then sync the index. This command changes AGENTS.md: review the diff before using it.

openskills sync

Manual method or another tool: attach SKILL.md to a conversation, or open it in an agent that can read files. Include any required supporting resources. No universal folder is recognized by every tool.

4. Try a first task

Use a short, synthetic input: an API endpoint, a small document or an interface brief. In a new conversation, attach the file or give the agent its path, then adapt this request:

Read .skill-downloads/multi-source-rfp-data-ingestion-mmlj2nx7/SKILL.md and apply its instructions to this example: [describe a test input]. Identify any missing files, dependencies or access. Produce the result requested by the skill without inventing data or running unreviewed scripts.

Expected result: output matching the skill instructions, or a precise list of missing prerequisites. A read confirmation alone does not validate a first use. If an external resource is unavailable, that path remains untested.

Related skills