Skip to content

Custom fields overview

The CommonGrants protocol defines a core set of fields that are widely applicable across federal, state, local, and private grant opportunities. However, many implementations need fields that are relevant to their specific context but aren’t universal enough to belong in the core protocol.

Custom fields solve this problem by providing a structured way to extend CommonGrants models with additional data. For the formal rules governing how custom fields work (e.g., which models support them, what implementations MUST and SHALL NOT do), see the custom fields section of the specification.

Each custom field includes:

Property Description
name Name of the custom field
fieldType The JSON schema type used when de-serializing the value
schema (Optional) Link to the full JSON schema for the field
value The field’s value
description (Optional) Description of the custom field’s purpose

Custom fields live in the customFields property of any CommonGrants model, allowing implementations to carry additional data while staying compatible with the base protocol.

The custom field catalog is a shared repository of pre-defined custom fields that are not part of the core CommonGrants library but are reused across implementations.

The catalog has two primary goals:

  1. Standardize semantically equivalent fields. Without a shared catalog, two different teams might define fields like “agency” with slightly different names, types, or structures. This makes interoperability harder and increases integration costs. The catalog gives implementers a single place to find and reuse existing field definitions before creating their own.

  2. Create a path for promoting fields into the core protocol. As custom fields gain adoption across implementations, they become candidates for inclusion in the base protocol. The catalog provides visibility into which fields are widely used, creating a foundation for a “field promotion” pipeline where popular custom fields can move from the catalog into @common-grants/core.

The agency custom field is deprecated for opportunities. Protocol v0.5 added funders to OpportunityBase, so the organization offering an opportunity now has a home in the core protocol and no longer needs a custom field.

The field is not deprecated for competitions or forms, which have no funders equivalent. Only opportunity usage should migrate.

funders is an OrgRefCollection: a required primary organization plus optional otherOrgs keyed by role. The four agency values map onto it as follows.

agency value Maps to
name funders.primary.name
code funders.primary.identifiers.otherIds["org:grants.gov:agency"].id
parentName funders.otherOrgs.<role>.name
parentCode funders.otherOrgs.<role>.identifiers.otherIds["org:grants.gov:agency"].id

An opportunity that previously carried this custom field:

{
"customFields": {
"agency": {
"name": "agency",
"fieldType": "object",
"value": {
"code": "HRSA",
"name": "Health Resources and Services Administration",
"parentCode": "HHS",
"parentName": "Health and Human Services"
}
}
}
}

carries the same information under funders. The parent role key below is illustrative: otherOrgs is keyed by an implementation-defined role, and which key names the parent agency is one of the plugin decisions listed above.

{
"funders": {
"primary": {
"id": "018f2e77-4b5c-7d2e-9f3a-bcdef1234567",
"name": "Health Resources and Services Administration",
"identifiers": {
"otherIds": {
"org:grants.gov:agency": {
"registry": { "code": "org:grants.gov:agency" },
"id": "HRSA"
}
}
}
},
"otherOrgs": {
"parent": {
"id": "018f2e77-4b5c-7d2e-9f3a-bcdef1234568",
"name": "Health and Human Services",
"identifiers": {
"otherIds": {
"org:grants.gov:agency": {
"registry": { "code": "org:grants.gov:agency" },
"id": "HHS"
}
}
}
}
}
}
}

Note that org:grants.gov:agency is not a registered code in the identifier registry catalog. It is carried under otherIds, which is where unregistered codes belong.

A migration MUST NOT silently discard data. If a source record supplies any of code, name, parentCode, or parentName, every value it supplies has to survive the mapping. Dropping the parent agency because OrgRefCollection has only one primary is a loss, not a simplification: the parent belongs under otherOrgs.

OrgRef requires both id and name, and grants.gov agency records supply neither a UUID nor, in every case, a full name. Resolving that gap is what makes this a mapping decision rather than a rename.

The exact grants.gov mapping lives in the TypeScript and Python plugin packages, not here. Those tickets decide:

  • How to derive the required OrgRef.id deterministically from a source record, or whether to fail the record when no stable identifier exists.
  • Whether agency keeps being emitted alongside funders during the transition, and which one wins when a record carries both.
  • Which otherOrgs role key names the parent agency, so consumers can rely on it.
  • Whether to register org:grants.gov:agency in the registry catalog, which would move it out of otherIds.

Both the TypeScript and Python SDKs provide helper functions for extending base models with typed custom fields.

Before adding custom fields to a model, you define a spec for each field that describes its type and (optionally) a schema for validating its value.

import { z } from "zod";
import { OpportunityBaseSchema } from "@common-grants/sdk/schemas";
import { CustomFieldType } from "@common-grants/sdk/constants";
import { withCustomFields } from "@common-grants/sdk/extensions";
// Define a value schema for structured types
const CostSharingValueSchema = z.object({
isRequired: z.boolean().optional(),
percentage: z.number().optional(),
details: z.string().optional(),
});
// Extend the base schema with typed custom fields
const OpportunitySchema = withCustomFields(OpportunityBaseSchema, {
costSharing: {
fieldType: CustomFieldType.object,
value: CostSharingValueSchema,
description: "Cost sharing or matching requirement for the opportunity",
},
category: {
fieldType: CustomFieldType.string,
description: "Grant category",
},
} as const);

Once you’ve defined your extended schema, use it to parse and validate incoming data. Registered custom fields get full type safety, while unregistered fields pass through validation with the base CustomField type.

import { getCustomFieldValue } from "@common-grants/sdk/extensions";
// Parse incoming data
const opportunity = OpportunitySchema.parse(responseData);
// Access custom fields directly on the parsed object
const costSharingField = opportunity.customFields?.costSharing;
console.log(costSharingField?.value.isRequired); // typed as boolean | undefined
console.log(costSharingField?.value.percentage); // typed as number | undefined
// Or use getCustomFieldValue() to extract just the value
const costSharing = getCustomFieldValue(
opportunity.customFields,
"costSharing",
CostSharingValueSchema,
);
console.log(costSharing?.isRequired); // typed as boolean | undefined
const category = getCustomFieldValue(
opportunity.customFields,
"category",
z.string(),
);
console.log(category); // typed as string | undefined

The custom field catalog is open to contributions. If you have a field that could be useful across multiple CommonGrants implementations, we encourage you to propose it.

Consider contributing a custom field when:

  • You’ve defined a field that other implementers are likely to need
  • An existing field in the catalog doesn’t quite fit your use case (propose a modification)
  • You want to help standardize a field that multiple implementations are already defining independently

New custom fields are proposed by opening a pull request against the CommonGrants repository. At a high level, the process involves:

  1. Define the field in TypeSpec. Create a new .tsp file in website/src/specs/custom-fields/ that extends CustomField with your field’s metadata.

    Here’s the fiscalYear field, one of the simpler entries in the catalog:

    import "@common-grants/core";
    import "@typespec/json-schema";
    import "./index.tsp";
    using CommonGrants.Fields;
    using JsonSchema;
    namespace CustomFields.FiscalYear;
    /** The fiscal year associated with this opportunity */
    @extension("x-tags", #[Tags.funding])
    @extension("x-author", "CommonGrants")
    @extension("x-version", "0.1.0")
    @extension("x-valid-schemas", #[ValidSchemas.OpportunityBase])
    model CustomFiscalYear extends CustomField {
    name: "fiscalYear";
    fieldType: CustomFieldType.integer;
    @example(2025)
    value: integer;
    description: "The fiscal year associated with this opportunity";
    }
  2. Register the field. Add an import for your new file in website/src/specs/custom-fields/index.tsp and add an entry to website/src/content/custom-fields/index.json with a schema reference and timestamps.

  3. Open a pull request. Submit your PR following the contributing guidelines. Include context about why the field is useful and which implementations could benefit from it.

For detailed instructions on building and testing the website locally (including compiling TypeSpec and previewing your changes), see the website’s DEVELOPMENT.md.

  • Check the catalog first. Before defining a new field, browse the custom field catalog to see if a similar field already exists. If one is close but not quite right, consider proposing a modification instead.
  • Use descriptive names. Field names should clearly convey their purpose. Prefer specific names like federalOpportunityNumber over generic ones like refNumber.
  • Choose the right type. Use the most specific CustomFieldType for your field’s value. For structured data, use object with well-documented properties.
  • Tag appropriately. Use the tags defined in index.tsp (e.g., identifier, federal, organization) to help users discover your field through search and filtering.
  • Specify valid schemas. Indicate which base models your field applies to (e.g., OpportunityBase, CompetitionBase) using the x-valid-schemas extension.
  • Include examples. Add @example decorators so that users can see realistic sample values in the catalog and generated documentation.