← Monday's Prompts

Automate Grant Applications 🚀

Turn Monday's 3 prompts into production-ready grant pipeline

October 7, 2025
💰 Fundraising🐍 Python + TypeScript⚡ 5 → 500 grants/month

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.

15+ hours
Per week on manual grant research
60% miss
Deadlines due to scattered tracking
Max 10/month
Applications with manual process

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

1

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
2

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
3

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
4

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

Grant research (databases, Google searches)
10 hours/week
$0
Application writing (copy-paste, formatting)
8 hours per application
$0
Deadline tracking (spreadsheets, reminders)
2 hours/week
$0
Document management (finding, updating files)
3 hours/week
$0
Total:$5,100/month in staff time
per month

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

Grant database APIs (Candid + GrantStation)
Subscription fees
$200/month
OpenAI API (GPT-4 for matching + drafting)
~50 grants × $3 per grant
$150/month
Airtable Pro (tracking database)
Team plan
$20/month
Google Workspace (Calendar API)
Business Starter
$12/month
Staff time (review and finalize)
4 hours/week × $50/hr
$800/month
Total:$1,182/month
per month

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
$130/day saved
77% cost reduction | $3,918/month | $47,016/year
💡 Pays for itself in first month
💰

Want This Running in Your Development Office?

We build custom grant automation systems that integrate with your existing databases, match your foundation relationships, and scale with your fundraising goals. From discovery to submission to tracking.