The Problem
On Monday you tested the 3 prompts in ChatGPT. You saw how discovery → matching → application works. But here's the reality: your development team can't spend 40 hours/week searching databases, copying requirements into Word docs, and manually tracking 50 different deadlines. You're leaving money on the table because you don't have time to apply.
See It Work
Watch the 3 prompts chain together automatically. This is what you'll build.
The Code
Three levels: start simple, add reliability, then scale to production. Pick where you are.
Level 1: Simple API Calls
Good for: 0-20 grants/month | Setup time: 30 minutes
# Simple Grant Discovery & Matching (0-20 grants/month) import openai import json from datetime import datetime, timedelta def discover_grants(org_profile: dict) -> dict: """Chain the 3 prompts: discover → match → draft""" # Step 1: Search grant databases (simplified - use real APIs in production) search_prompt = f"""Based on this nonprofit profile, identify 5 matching grant opportunities. Include foundation name, amount range, deadline, match score (0-100), and why it matches. Organization Profile: {json.dumps(org_profile, indent=2)} Format as JSON array with: grant_id, foundation, amount_range, deadline, match_score, match_reasons, requirements.""" response = openai.chat.completions.create( model="gpt-4", messages=[{"role": "user", "content": search_prompt}], temperature=0.3 ) matched_grants = json.loads(response.choices[0].message.content) # Step 2: Auto-fill application for top match top_grant = matched_grants[0] application_prompt = f"""Generate a complete grant application draft for this opportunity. Include: executive_summary (100 words), program_description (500 words), outcomes_metrics (200 words), and budget_narrative with line items. Organization: {json.dumps(org_profile, indent=2)} Grant Opportunity: {json.dumps(top_grant, indent=2)} Format as JSON with sections: executive_summary, program_description, outcomes_metrics, budget_narrative.""" response = openai.chat.completions.create( model="gpt-4", messages=[{"role": "user", "content": application_prompt}], temperature=0.5 ) application_draft = json.loads(response.choices[0].message.content) # Step 3: Create deadline tracking tracking = [] for grant in matched_grants: deadline = datetime.strptime(grant['deadline'], '%Y-%m-%d') days_until = (deadline - datetime.now()).days tracking.append({ 'grant_id': grant['grant_id'], 'foundation': grant['foundation'], 'amount': grant['amount_range'][1], # Use max amount 'deadline': grant['deadline'], 'days_until_deadline': days_until, 'priority': 'urgent' if days_until < 30 else 'high' if days_until < 60 else 'medium', 'status': 'draft_in_progress' if grant == top_grant else 'not_started' }) # Sort by deadline tracking.sort(key=lambda x: x['days_until_deadline']) return { 'matched_grants': matched_grants, 'top_application_draft': application_draft, 'deadline_tracking': tracking, 'total_potential_funding': sum(g['amount_range'][1] for g in matched_grants) } # Usage org_profile = { 'name': 'Youth Education Initiative', 'location': 'Chicago, IL', 'annual_budget': 1200000, 'beneficiaries': 500, 'focus_areas': ['education', 'youth development', 'STEM', 'college access'], 'funding_need': { 'amount_range': [50000, 150000], 'purpose': 'STEM program expansion and instructor hiring' } } result = discover_grants(org_profile) print(f"Found {len(result['matched_grants'])} grants") print(f"Total potential: ${result['total_potential_funding']:,}") print(f"Next deadline: {result['deadline_tracking'][0]['days_until_deadline']} days")
Level 2: With Database Integration & Calendar Sync
Good for: 20-100 grants/month | Setup time: 2 hours
// With Database Integration & Calendar Sync (20-100 grants/month) import Anthropic from '@anthropic-ai/sdk'; import { google } from 'googleapis'; import Airtable from 'airtable'; interface GrantResult { matched_grants: any[]; application_draft: any; calendar_events: string[]; tracking_records: string[]; } async function automateGrantWorkflow( orgProfile: any, candidApiKey: string, googleAuth: any, airtableKey: string ): Promise<GrantResult> { const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const calendar = google.calendar({ version: 'v3', auth: googleAuth }); const base = new Airtable({ apiKey: airtableKey }).base('appGrantTracking'); // Step 1: Search Candid Foundation Directory API const searchResponse = await fetch( 'https://api.candid.org/grants/v1/search', { method: 'POST', headers: { Authorization: `Bearer ${candidApiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ location: orgProfile.location, focus_areas: orgProfile.focus_areas, amount_min: orgProfile.funding_need.amount_range[0], amount_max: orgProfile.funding_need.amount_range[1], deadline_after: new Date().toISOString(), limit: 10, }), } ); const candidResults = await searchResponse.json(); // Step 2: Use Claude to match and rank const matchResponse = await anthropic.messages.create({ model: 'claude-3-5-sonnet-20241022', max_tokens: 4096, messages: [ { role: 'user', content: `Analyze these grant opportunities and rank them by fit (0-100 score). Organization: ${JSON.stringify(orgProfile)} Grants: ${JSON.stringify(candidResults.grants)} Return JSON array with: grant_id, match_score, match_reasons (array), priority_rank.`, }, ], }); const content = matchResponse.content[0]; if (content.type !== 'text') throw new Error('Invalid response'); const rankedGrants = JSON.parse(content.text); // Step 3: Generate application for top grant const topGrant = rankedGrants[0]; const appResponse = await anthropic.messages.create({ model: 'claude-3-5-sonnet-20241022', max_tokens: 8192, messages: [ { role: 'user', content: `Generate complete grant application sections. Org: ${JSON.stringify(orgProfile)} Grant: ${JSON.stringify(topGrant)} Include: executive_summary, program_description, outcomes_metrics, budget_narrative with line items. Format as JSON.`, }, ], }); const appContent = appResponse.content[0]; if (appContent.type !== 'text') throw new Error('Invalid response'); const applicationDraft = JSON.parse(appContent.text); // Step 4: Create calendar events for deadlines const calendarEvents: string[] = []; for (const grant of rankedGrants.slice(0, 5)) { const deadline = new Date(grant.deadline); const reminderDate = new Date(deadline); reminderDate.setDate(reminderDate.getDate() - 7); // 1 week before const event = await calendar.events.insert({ calendarId: 'primary', requestBody: { summary: `Grant Deadline: ${grant.foundation}`, description: `Application due for ${grant.foundation}\nAmount: $${grant.amount_range[1]}\nMatch Score: ${grant.match_score}`, start: { dateTime: deadline.toISOString() }, end: { dateTime: deadline.toISOString() }, reminders: { useDefault: false, overrides: [ { method: 'email', minutes: 7 * 24 * 60 }, // 1 week { method: 'popup', minutes: 24 * 60 }, // 1 day ], }, }, }); calendarEvents.push(event.data.id || ''); } // Step 5: Store in Airtable for tracking const trackingRecords: string[] = []; for (const grant of rankedGrants) { const record = await base('Grants').create({ 'Grant ID': grant.grant_id, Foundation: grant.foundation, 'Amount Max': grant.amount_range[1], Deadline: grant.deadline, 'Match Score': grant.match_score, Status: grant === topGrant ? 'Draft In Progress' : 'Not Started', Priority: grant.match_score > 90 ? 'High' : 'Medium', 'Application Draft': grant === topGrant ? JSON.stringify(applicationDraft) : '', }); trackingRecords.push(record.id); } return { matched_grants: rankedGrants, application_draft: applicationDraft, calendar_events: calendarEvents, tracking_records: trackingRecords, }; } // Usage with error handling try { const result = await automateGrantWorkflow( orgProfile, process.env.CANDID_API_KEY!, googleAuth, process.env.AIRTABLE_API_KEY! ); console.log(`Matched ${result.matched_grants.length} grants`); console.log(`Created ${result.calendar_events.length} calendar reminders`); console.log(`Tracking ${result.tracking_records.length} applications`); } catch (error) { console.error('Grant automation failed:', error); // Fallback to manual process }
Level 3: Production Pattern with LangGraph
Good for: 100+ grants/month | Setup time: 1 day
# Production Pattern with LangGraph (100+ grants/month) from langgraph.graph import Graph, END from typing import TypedDict, List import openai import requests from datetime import datetime, timedelta import logging logger = logging.getLogger('grant_automation') class GrantState(TypedDict): org_profile: dict search_results: List[dict] matched_grants: List[dict] prioritized_grants: List[dict] application_drafts: dict calendar_synced: bool tracking_updated: bool errors: List[str] def search_databases_node(state: GrantState) -> GrantState: """Search multiple grant databases in parallel""" try: # Search Candid Foundation Directory candid_response = requests.post( 'https://api.candid.org/grants/v1/search', headers={'Authorization': f'Bearer {os.getenv("CANDID_API_KEY")}'}, json={ 'location': state['org_profile']['location'], 'focus_areas': state['org_profile']['focus_areas'], 'amount_min': state['org_profile']['funding_need']['amount_range'][0], 'amount_max': state['org_profile']['funding_need']['amount_range'][1], 'limit': 50 }, timeout=30 ) candid_grants = candid_response.json().get('grants', []) # Search GrantStation (if subscribed) grantstation_response = requests.post( 'https://api.grantstation.com/v2/search', headers={'X-API-Key': os.getenv('GRANTSTATION_API_KEY')}, json={ 'keywords': ' '.join(state['org_profile']['focus_areas']), 'location': state['org_profile']['location'], 'funding_range': state['org_profile']['funding_need']['amount_range'] }, timeout=30 ) grantstation_grants = grantstation_response.json().get('results', []) # Combine and deduplicate all_grants = candid_grants + grantstation_grants unique_grants = {g['grant_id']: g for g in all_grants}.values() state['search_results'] = list(unique_grants) logger.info(f"Found {len(state['search_results'])} unique grants") except Exception as e: state['errors'].append(f"Database search failed: {str(e)}") logger.error(f"Search error: {e}") return state def match_and_rank_node(state: GrantState) -> GrantState: """Use LLM to match grants to org profile""" try: response = openai.chat.completions.create( model="gpt-4", messages=[{ "role": "user", "content": f"""Analyze these grants and rank by fit (0-100). Org: {json.dumps(state['org_profile'])} Grants: {json.dumps(state['search_results'][:20])} # Batch process For each grant, provide: - match_score (0-100) - match_reasons (array of specific alignments) - red_flags (array of potential issues) - estimated_effort_hours (time to complete application) Return JSON array sorted by match_score descending.""" }], temperature=0.3 ) matched = json.loads(response.choices[0].message.content) state['matched_grants'] = matched logger.info(f"Ranked {len(matched)} grants, top score: {matched[0]['match_score']}") except Exception as e: state['errors'].append(f"Matching failed: {str(e)}") logger.error(f"Match error: {e}") return state def prioritize_by_deadline_node(state: GrantState) -> GrantState: """Balance match score with deadline urgency""" try: now = datetime.now() for grant in state['matched_grants']: deadline = datetime.strptime(grant['deadline'], '%Y-%m-%d') days_until = (deadline - now).days # Calculate priority score: match_score * urgency_multiplier if days_until < 14: urgency = 2.0 # Double weight for urgent deadlines elif days_until < 30: urgency = 1.5 elif days_until < 60: urgency = 1.2 else: urgency = 1.0 grant['priority_score'] = grant['match_score'] * urgency grant['days_until_deadline'] = days_until grant['priority_level'] = ( 'urgent' if days_until < 14 else 'high' if days_until < 30 else 'medium' if days_until < 60 else 'low' ) # Sort by priority score state['prioritized_grants'] = sorted( state['matched_grants'], key=lambda x: x['priority_score'], reverse=True ) logger.info(f"Prioritized {len(state['prioritized_grants'])} grants") except Exception as e: state['errors'].append(f"Prioritization failed: {str(e)}") logger.error(f"Priority error: {e}") return state def generate_applications_node(state: GrantState) -> GrantState: """Generate drafts for top 3 grants""" try: drafts = {} # Only draft top 3 to avoid API costs for grant in state['prioritized_grants'][:3]: response = openai.chat.completions.create( model="gpt-4", messages=[{ "role": "user", "content": f"""Generate complete grant application. Org: {json.dumps(state['org_profile'])} Grant: {json.dumps(grant)} Include all required sections from grant requirements. Match foundation's language and priorities. Use specific metrics and outcomes. Format as JSON with sections matching application requirements.""" }], temperature=0.5 ) draft = json.loads(response.choices[0].message.content) drafts[grant['grant_id']] = draft logger.info(f"Generated draft for {grant['foundation']}") state['application_drafts'] = drafts except Exception as e: state['errors'].append(f"Draft generation failed: {str(e)}") logger.error(f"Draft error: {e}") return state def sync_calendar_node(state: GrantState) -> GrantState: """Create Google Calendar events for deadlines""" try: from google.oauth2.credentials import Credentials from googleapiclient.discovery import build creds = Credentials.from_authorized_user_file('token.json') service = build('calendar', 'v3', credentials=creds) for grant in state['prioritized_grants'][:10]: # Top 10 only deadline = datetime.strptime(grant['deadline'], '%Y-%m-%d') # Create deadline event event = { 'summary': f"📝 Grant Deadline: {grant['foundation']}", 'description': f"""Amount: ${grant['amount_range'][1]:,} Match Score: {grant['match_score']}/100 Priority: {grant['priority_level']} Application Status: {'Draft Ready' if grant['grant_id'] in state['application_drafts'] else 'Not Started'}""", 'start': {'date': grant['deadline']}, 'end': {'date': grant['deadline']}, 'reminders': { 'useDefault': False, 'overrides': [ {'method': 'email', 'minutes': 7 * 24 * 60}, # 1 week {'method': 'email', 'minutes': 3 * 24 * 60}, # 3 days {'method': 'popup', 'minutes': 24 * 60}, # 1 day ] } } service.events().insert(calendarId='primary', body=event).execute() state['calendar_synced'] = True logger.info("Calendar events created") except Exception as e: state['errors'].append(f"Calendar sync failed: {str(e)}") logger.error(f"Calendar error: {e}") return state def update_tracking_node(state: GrantState) -> GrantState: """Update Airtable tracking database""" try: from airtable import Airtable base = Airtable('appGrantTracking', 'Grants', api_key=os.getenv('AIRTABLE_API_KEY')) for grant in state['prioritized_grants']: record = { 'Grant ID': grant['grant_id'], 'Foundation': grant['foundation'], 'Amount Max': grant['amount_range'][1], 'Deadline': grant['deadline'], 'Match Score': grant['match_score'], 'Priority': grant['priority_level'], 'Days Until Deadline': grant['days_until_deadline'], 'Status': 'Draft Ready' if grant['grant_id'] in state['application_drafts'] else 'Not Started', 'Application Draft': json.dumps(state['application_drafts'].get(grant['grant_id'], {})), 'Last Updated': datetime.now().isoformat() } # Check if record exists existing = base.search('Grant ID', grant['grant_id']) if existing: base.update(existing[0]['id'], record) else: base.insert(record) state['tracking_updated'] = True logger.info("Tracking database updated") except Exception as e: state['errors'].append(f"Tracking update failed: {str(e)}") logger.error(f"Tracking error: {e}") return state def check_completion(state: GrantState) -> str: """Route based on success""" if state['errors']: return "has_errors" if state['calendar_synced'] and state['tracking_updated']: return "complete" return "incomplete" # Build the graph def build_grant_automation_graph(): graph = Graph() # Add nodes graph.add_node("search_databases", search_databases_node) graph.add_node("match_and_rank", match_and_rank_node) graph.add_node("prioritize", prioritize_by_deadline_node) graph.add_node("generate_applications", generate_applications_node) graph.add_node("sync_calendar", sync_calendar_node) graph.add_node("update_tracking", update_tracking_node) # Add edges graph.set_entry_point("search_databases") graph.add_edge("search_databases", "match_and_rank") graph.add_edge("match_and_rank", "prioritize") graph.add_edge("prioritize", "generate_applications") graph.add_edge("generate_applications", "sync_calendar") graph.add_edge("sync_calendar", "update_tracking") graph.add_conditional_edges( "update_tracking", check_completion, { "complete": END, "incomplete": "sync_calendar", # Retry sync "has_errors": END # Log and alert } ) return graph.compile() # Usage grant_graph = build_grant_automation_graph() initial_state = { 'org_profile': org_profile, 'search_results': [], 'matched_grants': [], 'prioritized_grants': [], 'application_drafts': {}, 'calendar_synced': False, 'tracking_updated': False, 'errors': [] } result = grant_graph.invoke(initial_state) if result['errors']: logger.error(f"Automation completed with errors: {result['errors']}") else: logger.info(f"Successfully processed {len(result['prioritized_grants'])} grants") logger.info(f"Generated {len(result['application_drafts'])} application drafts") logger.info(f"Calendar synced: {result['calendar_synced']}") logger.info(f"Tracking updated: {result['tracking_updated']}")
When to Level Up
Start: Simple API Calls
0-20 grants/month
- Sequential API calls to OpenAI for matching and drafting
- Manual deadline tracking in spreadsheet
- Copy-paste application sections into grant portals
Scale: Add Database Integration
20-100 grants/month
- Connect to Candid/GrantStation APIs for automated discovery
- Google Calendar sync for deadline reminders (1 week, 3 days, 1 day)
- Airtable for centralized tracking and status updates
- Email notifications for urgent deadlines
Production: Workflow Orchestration
100-500 grants/month
- LangGraph for complex workflows (search → match → prioritize → draft → sync)
- Parallel processing of multiple grant databases
- Conditional routing based on match scores and deadlines
- Automatic retry logic for API failures
- Human-in-the-loop for final review of top applications
Enterprise: Multi-Team Coordination
500+ grants/month
- Multi-agent system (research agent, writing agent, compliance agent, submission agent)
- Team collaboration features (assign applications to staff, track progress)
- Integration with DocuSign for e-signatures
- Automated follow-up emails to program officers
- Success rate analytics and foundation relationship tracking
- Budget forecasting and cash flow projection based on pending applications
Fundraising-Specific Gotchas
The code examples above work. But grant automation has unique challenges you need to handle.
Foundation Relationship Management
Grant databases don't show relationship history. Before auto-applying, check if you've applied before, if they've funded you, or if there's an existing relationship. Foundations hate duplicate or inappropriate applications.
import sqlite3 def check_foundation_history(foundation_name: str) -> dict: """Check past interactions before applying""" conn = sqlite3.connect('foundation_history.db') cursor = conn.cursor() # Check past applications cursor.execute(""" SELECT application_date, outcome, amount_requested, notes FROM applications WHERE foundation_name = ? ORDER BY application_date DESC LIMIT 5 """, (foundation_name,)) past_applications = cursor.fetchall() # Check if currently funded cursor.execute(""" SELECT grant_amount, grant_period_end FROM active_grants WHERE foundation_name = ? """, (foundation_name,)) active_grant = cursor.fetchone() # Check relationship notes cursor.execute(""" SELECT last_contact_date, program_officer, relationship_strength FROM foundation_relationships WHERE foundation_name = ? """, (foundation_name,)) relationship = cursor.fetchone() conn.close() return { 'has_applied_before': len(past_applications) > 0, 'last_application': past_applications[0] if past_applications else None, 'currently_funded': active_grant is not None, 'relationship_exists': relationship is not None, 'should_apply': ( # Don't apply if rejected in last 12 months not any(app[1] == 'rejected' and (datetime.now() - datetime.strptime(app[0], '%Y-%m-%d')).days < 365 for app in past_applications) ) } # Use before applying history = check_foundation_history('Chicago Community Trust') if not history['should_apply']: print(f"Skip: Recently rejected or duplicate application")
Funder-Specific Language Requirements
Each foundation has preferred terminology. Some want 'clients', others 'participants' or 'beneficiaries'. Some require specific outcome frameworks (Theory of Change, Logic Model). LLMs need foundation-specific style guides.
// Store foundation style preferences const foundationStyles = { 'chicago-community-trust': { terminology: { 'people_served': 'participants', 'outcomes': 'impact metrics', 'evaluation': 'assessment approach' }, required_sections: [ 'executive_summary', 'program_description', 'outcomes_metrics', 'budget_narrative', 'board_diversity_statement' ], preferred_framework: 'logic_model', word_limits: { executive_summary: 100, program_description: 500, outcomes_metrics: 200 }, tone: 'formal but accessible', emphasis: ['measurable outcomes', 'community engagement', 'sustainability'] }, 'joyce-foundation': { terminology: { 'people_served': 'beneficiaries', 'outcomes': 'systemic change indicators', 'evaluation': 'equity impact assessment' }, required_sections: [ 'theory_of_change', 'equity_analysis', 'policy_advocacy_strategy' ], preferred_framework: 'theory_of_change', tone: 'policy-focused', emphasis: ['systemic change', 'equity', 'policy advocacy'] } }; async function generateFoundationSpecificDraft( orgProfile: any, grant: any ): Promise<any> { const foundationSlug = grant.foundation.toLowerCase().replace(/\s+/g, '-'); const style = foundationStyles[foundationSlug]; if (!style) { console.warn(`No style guide for ${grant.foundation}, using generic`); } const prompt = `Generate grant application using foundation-specific language. Foundation: ${grant.foundation} Preferred terms: ${JSON.stringify(style?.terminology || {})} Required sections: ${style?.required_sections.join(', ')} Tone: ${style?.tone} Emphasize: ${style?.emphasis.join(', ')} Organization: ${JSON.stringify(orgProfile)} Grant: ${JSON.stringify(grant)} Match their language exactly. Use their preferred framework.`; // Generate with style-specific prompt const response = await anthropic.messages.create({ model: 'claude-3-5-sonnet-20241022', max_tokens: 8192, messages: [{ role: 'user', content: prompt }] }); return JSON.parse(response.content[0].text); }
Deadline vs. LOI vs. Full Application Timing
Many grants have multi-stage processes: Letter of Inquiry (LOI) due first, then invited for full application. Your automation needs to track which stage you're at and not auto-generate full applications for LOI-only opportunities.
from enum import Enum from datetime import datetime, timedelta class ApplicationStage(Enum): LOI_ONLY = "loi_only" # Letter of Inquiry required first FULL_APP = "full_application" # Direct to full application INVITED_ONLY = "invited_only" # By invitation after LOI TWO_STAGE = "two_stage" # LOI then full if invited def determine_application_approach(grant: dict) -> dict: """Figure out what to generate based on grant stage""" stage = ApplicationStage(grant.get('application_type', 'full_application')) deadline = datetime.strptime(grant['deadline'], '%Y-%m-%d') if stage == ApplicationStage.LOI_ONLY: return { 'generate': 'letter_of_inquiry', 'word_limit': 500, # LOIs typically shorter 'sections': ['organizational_overview', 'project_summary', 'funding_request'], 'next_step': 'wait_for_invitation', 'estimated_time': '2 hours' } elif stage == ApplicationStage.TWO_STAGE: # Check if we've been invited invitation = check_invitation_status(grant['grant_id']) if invitation and invitation['invited']: # Generate full application full_deadline = invitation['full_application_deadline'] return { 'generate': 'full_application', 'deadline': full_deadline, 'sections': grant['required_sections'], 'estimated_time': '8-12 hours' } else: # Still at LOI stage return { 'generate': 'letter_of_inquiry', 'deadline': grant['deadline'], 'next_step': 'wait_for_invitation', 'estimated_time': '2 hours' } elif stage == ApplicationStage.INVITED_ONLY: # Don't auto-generate unless invited invitation = check_invitation_status(grant['grant_id']) if not invitation or not invitation['invited']: return { 'generate': None, 'action': 'contact_program_officer', 'message': 'This is invitation-only. Build relationship first.' } else: # FULL_APP return { 'generate': 'full_application', 'deadline': grant['deadline'], 'sections': grant['required_sections'], 'estimated_time': '8-12 hours' } # Use in workflow approach = determine_application_approach(grant) if approach['generate']: draft = generate_application(org_profile, grant, approach['generate']) else: print(f"Action needed: {approach['action']}")
Budget Alignment Across Multiple Grants
You can't request $150K for the same program from 5 different foundations. Funders check. Your automation needs to track total requested vs. total budget and flag overlaps.
interface BudgetRequest { grant_id: string; foundation: string; program: string; amount_requested: number; budget_categories: { [key: string]: number }; status: 'draft' | 'submitted' | 'awarded' | 'rejected'; } function validateBudgetAlignment( newRequest: BudgetRequest, existingRequests: BudgetRequest[], programBudget: number ): { valid: boolean; issues: string[] } { const issues: string[] = []; // Filter to same program, active requests only const sameProgram = existingRequests.filter( r => r.program === newRequest.program && ['draft', 'submitted'].includes(r.status) ); // Calculate total requested (including this new one) const totalRequested = sameProgram.reduce( (sum, r) => sum + r.amount_requested, newRequest.amount_requested ); // Check if over-requesting if (totalRequested > programBudget) { issues.push( `Total requested ($${totalRequested.toLocaleString()}) exceeds program budget ($${programBudget.toLocaleString()})` ); } // Check for duplicate budget line items const allCategories = new Map<string, number>(); [...sameProgram, newRequest].forEach(request => { Object.entries(request.budget_categories).forEach(([category, amount]) => { const current = allCategories.get(category) || 0; allCategories.set(category, current + amount); }); }); // Flag if any category is over-requested allCategories.forEach((totalRequested, category) => { // Assume each category shouldn't exceed 150% of reasonable amount const reasonable = programBudget * 0.3; // 30% per category max if (totalRequested > reasonable * 1.5) { issues.push( `${category}: $${totalRequested.toLocaleString()} requested across multiple grants (may raise red flags)` ); } }); // Check for foundation-specific restrictions const foundationNames = sameProgram.map(r => r.foundation); if (foundationNames.includes(newRequest.foundation)) { issues.push( `Already have pending application to ${newRequest.foundation} for this program` ); } return { valid: issues.length === 0, issues }; } // Use before generating application const validation = validateBudgetAlignment( newGrantRequest, existingApplications, programBudget ); if (!validation.valid) { console.warn('Budget alignment issues:', validation.issues); // Either adjust request amount or skip this grant }
Attachment Requirements Vary Wildly
Every foundation wants different attachments: some want 990s, some want audited financials, some want board lists with demographics, some want letters of support. Your automation needs a document library and smart matching.
import os from pathlib import Path from datetime import datetime class DocumentLibrary: def __init__(self, base_path: str): self.base_path = Path(base_path) self.document_index = self._build_index() def _build_index(self) -> dict: """Scan document library and index by type and date""" index = {} for file_path in self.base_path.rglob('*'): if file_path.is_file(): # Extract document type from filename or folder doc_type = self._classify_document(file_path) doc_date = self._extract_date(file_path) if doc_type not in index: index[doc_type] = [] index[doc_type].append({ 'path': str(file_path), 'date': doc_date, 'filename': file_path.name }) # Sort each type by date (newest first) for doc_type in index: index[doc_type].sort(key=lambda x: x['date'], reverse=True) return index def _classify_document(self, file_path: Path) -> str: """Determine document type from filename/path""" name_lower = file_path.name.lower() if '990' in name_lower: return 'irs_990' elif 'audit' in name_lower or 'financial' in name_lower: return 'audited_financials' elif 'determination' in name_lower: return '501c3_determination' elif 'board' in name_lower: return 'board_list' elif 'budget' in name_lower: return 'organizational_budget' elif 'letter' in name_lower and 'support' in name_lower: return 'letter_of_support' else: return 'other' def _extract_date(self, file_path: Path) -> datetime: """Extract year from filename or use file modification date""" import re # Try to find 4-digit year in filename match = re.search(r'(20\d{2})', file_path.name) if match: year = int(match.group(1)) return datetime(year, 12, 31) # Use end of year # Fall back to file modification time return datetime.fromtimestamp(file_path.stat().st_mtime) def match_requirements(self, grant_requirements: list) -> dict: """Match grant requirements to available documents""" matched = {} missing = [] for requirement in grant_requirements: req_type = self._normalize_requirement(requirement) if req_type in self.document_index and self.document_index[req_type]: # Use most recent document matched[requirement] = self.document_index[req_type][0] else: missing.append(requirement) return { 'matched_documents': matched, 'missing_documents': missing, 'ready_to_submit': len(missing) == 0 } def _normalize_requirement(self, requirement: str) -> str: """Map grant requirement text to document type""" req_lower = requirement.lower() mapping = { 'irs form 990': 'irs_990', '990': 'irs_990', 'audited financial': 'audited_financials', 'financial statement': 'audited_financials', '501(c)(3)': '501c3_determination', 'determination letter': '501c3_determination', 'board list': 'board_list', 'board roster': 'board_list', 'organizational budget': 'organizational_budget', 'annual budget': 'organizational_budget', 'letter of support': 'letter_of_support' } for key, value in mapping.items(): if key in req_lower: return value return 'other' # Usage in grant workflow doc_library = DocumentLibrary('/path/to/documents') for grant in matched_grants: attachments = doc_library.match_requirements(grant['requirements']) if attachments['ready_to_submit']: print(f"✓ All documents ready for {grant['foundation']}") # Proceed with application else: print(f"✗ Missing documents for {grant['foundation']}:") for doc in attachments['missing_documents']: print(f" - {doc}") # Flag for manual handling
Cost Calculator
Manual Process
Limitations:
- • Max 5-10 applications per month
- • 60% of deadlines missed due to tracking errors
- • No relationship or budget overlap tracking
- • Staff burnout from repetitive work
Automated System
Benefits:
- ✓ Process 50+ grants per month (10x increase)
- ✓ 100% deadline tracking accuracy
- ✓ Automatic budget alignment checks
- ✓ Foundation relationship history tracking
- ✓ Staff focuses on strategy, not data entry