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
Use it when adding a new RFP data source, changing ingestion logic, or debugging a failed RFPMart CSV upload.
Skip it for general-purpose data ingestion guidance or if your project does not use the Convex stack it references.
Security analysis
SafeThis 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.
- •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.
Examples
Add GovTribe as a P2 source in the ingestion pipeline and normalize its response to the canonical Opportunity schema, including deduplication against existing records.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.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 DevelopmentITES- IT ServicesNET- NetworkingTELCOM- TelecommunicationsDRA- Data & ResearchCSE- 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
- Navigate to Admin → Data Sources tab
- Scroll to RFPMart CSV Upload section
- Drop a CSV file or click to browse
- Toggle "Only import IT-relevant RFPs" if desired
- 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
- Exact match:
externalId+sourcecombination - Title similarity: Fuzzy match titles within same deadline window
- 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
- Mock API responses for unit tests
- Use sandbox/test endpoints when available
- Validate schema transformation
- Test deduplication logic
- 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 filesThis 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.mdFrom 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 installationopenskills 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 syncManual 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.
Create Interactive Maps with kepler.gl
Data & AI
Create interactive map visualizations and export standalone HTML using the keplergl Python package. Use to plot geospatial data from DataFrames, GeoJSON, or CSV.
Agent-Driven Parameter Optimization
Data & AI
Guide for optimizing configurable systems with a measurable objective, using bounded trials and evidence-based recommendations.
ICP Visitor Outreach
Data & AI
Identify signed-up users matching the ideal customer profile but stalled, and draft personalized outreach to re-engage them.