> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crazygoldfish.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a rubric with the API

> Build a scoring rubric with criteria and scoring bands, then attach it to an assignment so the AI grades handwriting or audio against your own standards.

A rubric tells the AI how to score work: what to look at, and what earns each score. It takes two API calls to build one, plus a few more to attach it to an assignment.

This guide is for developers. For a non-technical overview, see [Assignment Evaluation features](/products/assignment/features).

| Step | You call                            | You get back                                         |
| ---- | ----------------------------------- | ---------------------------------------------------- |
| 1    | Create a rubric                     | The rubric `id`                                      |
| 2    | Add a criterion, once per criterion | The whole rubric, with every criterion so far        |
| 3    | Attach the rubric to an assignment  | A model answer sheet that grades against your rubric |

<Info>
  Steps 1 and 2 are instant: no background job, nothing to poll. Only step 3 starts one, when you finalize the model answer sheet.
</Info>

## Set up

You need an access token and an assignment whose questions are already extracted. If you don't have a token, follow the [Quickstart](/api-reference/quickstart).

<CodeGroup>
  ```python Python theme={null}
  import time

  import requests

  BASE_URL = "https://api.crazygoldfish.com"
  HEADERS = {"Authorization": "Bearer YOUR_ACCESS_TOKEN"}

  MODEL_ANSWER = "/assignment-evaluation/v1/assignment/{}/model-answer/"


  def wait_for_model_answer(assignment_id):
      """Check the model answer sheet every 5 seconds."""
      path = MODEL_ANSWER.format(assignment_id)
      for _ in range(120):  # Give up after 10 minutes
          response = requests.get(BASE_URL + path, headers=HEADERS)
          response.raise_for_status()
          sheet = response.json()
          if sheet["status"] == 2:  # Completed
              return sheet
          if sheet["status"] == 3:  # Failed
              raise RuntimeError("Model answer sheet failed")
          time.sleep(5)
      raise TimeoutError("Model answer sheet took over 10 minutes")
  ```

  ```javascript JavaScript theme={null}
  const BASE_URL = "https://api.crazygoldfish.com";
  const HEADERS = { Authorization: "Bearer YOUR_ACCESS_TOKEN" };
  const JSON_HEADERS = { ...HEADERS, "Content-Type": "application/json" };

  const modelAnswerPath = (assignmentId) =>
    `/assignment-evaluation/v1/assignment/${assignmentId}/model-answer/`;

  // Check the model answer sheet every 5 seconds
  async function waitForModelAnswer(assignmentId) {
    const path = modelAnswerPath(assignmentId);
    for (let i = 0; i < 120; i++) { // Give up after 10 minutes
      const response = await fetch(BASE_URL + path, { headers: HEADERS });
      if (!response.ok) throw new Error(await response.text());
      const sheet = await response.json();
      if (sheet.status === 2) return sheet; // Completed
      if (sheet.status === 3) throw new Error("Model answer sheet failed");
      await new Promise((resolve) => setTimeout(resolve, 5000));
    }
    throw new Error("Model answer sheet took over 10 minutes");
  }
  ```
</CodeGroup>

<Note>
  The Rubrics and Assignment Evaluation paths all end in a slash, and it is required. Without it the API redirects, and most HTTP clients drop the request body when they follow a redirect. These APIs also answer `403`, not `401`, when a token is missing or expired.
</Note>

## Step 1: Create the rubric

Send a `name`, and a `description` if you want one. You get an empty rubric back.

<CodeGroup>
  ```python Python theme={null}
  response = requests.post(
      BASE_URL + "/rubrics/v1/rubric/",
      headers=HEADERS,
      json={
          "name": "Grade 5 English homework",
          "description": "Grades handwritten homework on grammar and clarity.",
      },
  )
  response.raise_for_status()
  rubric_id = response.json()["id"]
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(BASE_URL + "/rubrics/v1/rubric/", {
    method: "POST",
    headers: JSON_HEADERS,
    body: JSON.stringify({
      name: "Grade 5 English homework",
      description: "Grades handwritten homework on grammar and clarity.",
    }),
  });
  if (!response.ok) throw new Error(await response.text());
  const rubricId = (await response.json()).id;
  ```
</CodeGroup>

<Accordion title="What you get back">
  ```json theme={null}
  {
    "id": "2b973c83-2830-4712-87b7-64a8c0a99035",
    "name": "Grade 5 English homework",
    "description": "Grades handwritten homework on grammar and clarity.",
    "rubricCriteria": [],
    "createdAt": "2026-03-27T11:30:18.402117Z",
    "modifiedAt": "2026-03-27T11:30:18.402145Z"
  }
  ```

  `rubricCriteria` stays empty until you add a criterion in step 2.
</Accordion>

## Step 2: Add the criteria

Add one criterion per call. Each one needs a score range and a band for every score it can award.

<Warning>
  `maxScore` and `minScore` are **strings**, not numbers, and each holds at most 10 characters. `"5 Star"` works, `5` does not.
</Warning>

<CodeGroup>
  ```python Python theme={null}
  criteria = [
      {
          "name": "Grammar and mechanics",
          "description": "Spelling, punctuation, and sentences.",
          "maxScore": "5 Star",
          "minScore": "1 Star",
          "scoringLogic": [
              {"score": "5 Star", "scoringLogic": "No errors. Sentences vary."},
              {"score": "4 Star", "scoringLogic": "One or two small errors."},
              {"score": "3 Star", "scoringLogic": "Errors the reader notices."},
              {"score": "2 Star", "scoringLogic": "Some sentences are hard."},
              {"score": "1 Star", "scoringLogic": "Very hard to read."},
          ],
      },
      {
          "name": "Clarity and expression",
          "maxScore": "5 Star",
          "minScore": "1 Star",
          "scoringLogic": [
              {"score": "5 Star", "scoringLogic": "Clear on first reading."},
              {"score": "4 Star", "scoringLogic": "One or two vague spots."},
              {"score": "3 Star", "scoringLogic": "The reader has to work."},
              {"score": "2 Star", "scoringLogic": "Often vague or confusing."},
              {"score": "1 Star", "scoringLogic": "The meaning is lost."},
          ],
      },
  ]

  for criterion in criteria:
      response = requests.post(
          BASE_URL + f"/rubrics/v1/rubric/{rubric_id}/criteria/",
          headers=HEADERS,
          json=criterion,
      )
      response.raise_for_status()

  rubric = response.json()  # The whole rubric, not just the criterion
  ```

  ```javascript JavaScript theme={null}
  const criteria = [
    {
      name: "Grammar and mechanics",
      description: "Spelling, punctuation, and sentences.",
      maxScore: "5 Star",
      minScore: "1 Star",
      scoringLogic: [
        { score: "5 Star", scoringLogic: "No errors. Sentences vary." },
        { score: "4 Star", scoringLogic: "One or two small errors." },
        { score: "3 Star", scoringLogic: "Errors the reader notices." },
        { score: "2 Star", scoringLogic: "Some sentences are hard." },
        { score: "1 Star", scoringLogic: "Very hard to read." },
      ],
    },
    {
      name: "Clarity and expression",
      maxScore: "5 Star",
      minScore: "1 Star",
      scoringLogic: [
        { score: "5 Star", scoringLogic: "Clear on first reading." },
        { score: "4 Star", scoringLogic: "One or two vague spots." },
        { score: "3 Star", scoringLogic: "The reader has to work." },
        { score: "2 Star", scoringLogic: "Often vague or confusing." },
        { score: "1 Star", scoringLogic: "The meaning is lost." },
      ],
    },
  ];

  let rubric;
  for (const criterion of criteria) {
    const res = await fetch(
      `${BASE_URL}/rubrics/v1/rubric/${rubricId}/criteria/`,
      {
        method: "POST",
        headers: JSON_HEADERS,
        body: JSON.stringify(criterion),
      },
    );
    if (!res.ok) throw new Error(await res.text());
    rubric = await res.json(); // The whole rubric, not just the criterion
  }
  ```
</CodeGroup>

<Accordion title="What you get back">
  Each call returns the whole rubric, with the new criterion last in `rubricCriteria`. The code above keeps the answer to the last call, so both criteria are there.

  ```json theme={null}
  {
    "id": "2b973c83-2830-4712-87b7-64a8c0a99035",
    "name": "Grade 5 English homework",
    "description": "Grades handwritten homework on grammar and clarity.",
    "rubricCriteria": [
      {
        "id": "d102b0ff-1111-4235-b8e9-764ef03c57cb",
        "name": "Grammar and mechanics",
        "description": "Spelling, punctuation, and sentences.",
        "maxScore": "5 Star",
        "minScore": "1 Star",
        "scoringLogic": [
          { "score": "5 Star", "scoringLogic": "No errors. Sentences vary." },
          { "score": "1 Star", "scoringLogic": "Very hard to read." }
        ],
        "createdAt": "2026-03-27T11:34:53.172550Z",
        "modifiedAt": "2026-03-27T11:34:53.172581Z"
      },
      {
        "id": "9c4e7a15-63b2-4f80-a1d7-5e0cb8f2a934",
        "name": "Clarity and expression",
        "description": null,
        "maxScore": "5 Star",
        "minScore": "1 Star",
        "scoringLogic": [
          { "score": "5 Star", "scoringLogic": "Clear on first reading." },
          { "score": "1 Star", "scoringLogic": "The meaning is lost." }
        ],
        "createdAt": "2026-03-27T11:34:54.008914Z",
        "modifiedAt": "2026-03-27T11:34:54.008946Z"
      }
    ],
    "createdAt": "2026-03-27T11:30:18.402117Z",
    "modifiedAt": "2026-03-27T11:30:18.402145Z"
  }
  ```

  `scoringLogic` is trimmed here. A real response holds every band you sent.
</Accordion>

<Tip>
  To change a criterion later, take its `id` from `rubricCriteria` and call [Update a rubric criterion](/api-reference/endpoint/rubrics/v1-rubric-criteria-id-patch). Sending `scoringLogic` replaces every band, so send the full list.
</Tip>

## Step 3: Attach the rubric to an assignment

An assignment grades against a rubric through its model answer sheet. Create the sheet, attach the rubric, then finalize it. These are three separate calls, because the API accepts only one of `documents`, `rubricId`, and `isFinal` at a time.

<Warning>
  `isFinal` is the **string** `"true"`, not the boolean `true`.
</Warning>

<CodeGroup>
  ```python Python theme={null}
  assignment_id = "3c9e1f2a-7b4d-4a8e-9f1c-2d5e6a7b8c9d"
  path = MODEL_ANSWER.format(assignment_id)

  # 1. Create the model answer sheet
  response = requests.post(BASE_URL + path, headers=HEADERS)
  response.raise_for_status()
  sheet_id = response.json()["id"]

  # 2. Attach the rubric
  requests.patch(
      BASE_URL + f"{path}{sheet_id}/",
      headers=HEADERS,
      json={"rubricId": rubric_id},
  ).raise_for_status()

  # 3. Finalize, which starts the background job
  requests.patch(
      BASE_URL + f"{path}{sheet_id}/",
      headers=HEADERS,
      json={"isFinal": "true"},
  ).raise_for_status()

  sheet = wait_for_model_answer(assignment_id)
  ```

  ```javascript JavaScript theme={null}
  const assignmentId = "3c9e1f2a-7b4d-4a8e-9f1c-2d5e6a7b8c9d";
  const path = modelAnswerPath(assignmentId);

  // 1. Create the model answer sheet
  const created = await fetch(BASE_URL + path, {
    method: "POST",
    headers: HEADERS,
  });
  if (!created.ok) throw new Error(await created.text());
  const sheetId = (await created.json()).id;

  async function patchSheet(body) {
    const res = await fetch(`${BASE_URL}${path}${sheetId}/`, {
      method: "PATCH",
      headers: JSON_HEADERS,
      body: JSON.stringify(body),
    });
    if (!res.ok) throw new Error(await res.text());
    return res.json();
  }

  await patchSheet({ rubricId }); // 2. Attach the rubric
  await patchSheet({ isFinal: "true" }); // 3. Finalize

  const sheet = await waitForModelAnswer(assignmentId);
  ```
</CodeGroup>

<Accordion title="What you get back">
  Each call returns the model answer sheet. After step 2, `rubric` holds the whole rubric. `status` is an integer: `0` Open, `1` In Progress, `2` Completed, `3` Failed.

  ```json theme={null}
  {
    "id": "6e1d2c3b-4a5f-4e6d-8c7b-9a0f1e2d3c4b",
    "documents": [],
    "rubric": {
      "id": "2b973c83-2830-4712-87b7-64a8c0a99035",
      "name": "Grade 5 English homework",
      "rubricCriteria": [{ "...": "..." }]
    },
    "status": 1
  }
  ```

  Before you attach a rubric, `rubric` is `{}`.
</Accordion>

Once the sheet reaches `status` `2`, submit student work with [Create an assignment answer sheet](/api-reference/endpoint/assignments/v1-assignment-student-answer) and read the result with [Get an evaluated assignment score](/api-reference/endpoint/assignments/v1-assignment-student-answer-get-score).

<Note>
  Audio submissions are always graded against a rubric. If the assignment's model answer sheet has no rubric, the submission is rejected with `Rubric not found for audio evaluation`.
</Note>

## Good to know

<AccordionGroup>
  <Accordion title="Limits" icon="gauge">
    | Limit                                                          | Value                                                                                             |
    | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
    | Criteria per rubric                                            | Up to 20. The 21st returns `Maximum rubric criteria limit reached`.                               |
    | `maxScore` and `minScore`                                      | Strings of at most 10 characters each                                                             |
    | Criteria per call                                              | One. Call [Add a rubric criterion](/api-reference/endpoint/rubrics/v1-rubric-criteria) in a loop. |
    | Rubrics per model answer sheet                                 | One. Until the sheet is finalized, sending `rubricId` again replaces it.                          |
    | [List rubrics](/api-reference/endpoint/rubrics/v1-rubric-list) | 10 per page by default, up to 1000 with `page_size`                                               |
  </Accordion>

  <Accordion title="Write bands the grader can read" icon="pen">
    The grading model reads your band labels and reports one of them back as the student's level, so the wording matters.

    * Keep labels conventional and consistent across bands: `5 Star` down to `1 Star`, or `Level 4` down to `Level 1`. Don't mix styles inside one criterion.
    * Give every score between `minScore` and `maxScore` its own band. A missing band is a score the grader has no definition for.
    * Describe what the student did, not what the grader should do. "No spelling errors" works better than "award full marks if perfect".
    * Keep one idea per criterion. Two criteria with three bands each are easier to grade consistently than one criterion with six.
  </Accordion>

  <Accordion title="Troubleshooting" icon="wrench">
    | Error                                                                    | Fix                                                                                        |
    | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
    | `{"name": ["This field is required."]}`                                  | Send a rubric `name`                                                                       |
    | `{"maxScore": ["Ensure this field has no more than 10 characters."]}`    | Shorten the score label                                                                    |
    | `Maximum rubric criteria limit reached`                                  | The rubric already has 20 criteria. Use a second rubric.                                   |
    | `Rubric not found`                                                       | Wrong rubric ID, or the trailing slash is missing from the path                            |
    | `Rubric criteria not found`                                              | Wrong `criteriaId`. Read it from the rubric's `rubricCriteria`.                            |
    | `Assignment is not extracted yet`                                        | Wait until the assignment's questions are extracted before creating the model answer sheet |
    | `Only one of documents, rubricId, or isFinal can be processed at a time` | Split it into separate calls, as in step 3                                                 |
    | `Model answer sheet is already extracted`                                | The sheet is finalized and no longer accepts changes                                       |
    | `Rubric not found for audio evaluation`                                  | Attach a rubric to the assignment's model answer sheet before submitting audio             |
    | `403` with `Token has expired`                                           | [Get a new access token](/api-reference/endpoint/authentication)                           |
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card title="Rubrics API reference" icon="code" href="/api-reference/endpoint/rubrics/v1-rubric">
    Every field, response, and error.
  </Card>

  <Card title="Assignment Evaluation features" icon="clipboard-check" href="/products/assignment/features">
    What rubric grading does for teachers.
  </Card>
</CardGroup>
