One Profile Field, Four Length Limits. My AI Matcher Reads the Smallest.
Upwork Scout sends freelancers alerts for Upwork jobs that fit them. The "fit" part is a small Claude Haiku call that reads the freelancer's profile next to a job post and returns a score from 0 to 100. That score is onl
Upwork Scout sends freelancers alerts for Upwork jobs that fit them. The "fit" part is a small Claude Haiku call that reads the freelancer's profile next to a job post and returns a score from 0 to 100. That score is only as good as the profile it reads, so early on I built a shortcut for filling the profile in: open your own Upwork profile, press Cmd+A, copy, paste it into a box, and an LLM turns the mess into structured fields.
This week I went back through that feature to write about it, and found something I did not expect. The bio field, the one the textarea tells users to fill with "the more, the better matching", has four different length limits in four different places. The matcher reads the smallest one.
Here is how that happened and what I think the lesson is.
Paste beats scrape
The first version of "import my profile" in my head was a scraper: user gives me their Upwork URL, I fetch it. That falls apart fast. Upwork blocks bots, some profiles are private, and I would be maintaining selectors for a page I do not control.
The paste approach sidesteps all of it. The user is already logged in to their own profile. Select all, copy, paste. I get the page chrome, the navigation, the footer and somewhere in the middle the parts I actually want. Then the model's job is just to ignore the noise.
The route is about 80 lines. It checks the session, rejects anything under 200 characters with a hint about Cmd+A, sends the text to Haiku with a JSON schema, and returns the extracted fields without saving them:
const EXTRACT_SCHEMA = {
type: "object",
properties: {
headline: { type: "string", description: "Professional title/headline, max 120 chars" },
bio: { type: "string", description: "Rewrite of their overview/description + notable work history ... max 2500 chars." },
skills: { type: "array", items: { type: "string" }, description: "Up to 20 concrete skills/tools" },
lookingFor: { type: "string", description: "... what kind of jobs they seem to want ... max 600 chars." },
minRate: { type: ["number", "null"], description: "Their hourly rate in USD if shown, else null" },
},
required: ["headline", "bio", "skills", "lookingFor", "minRate"],
additionalProperties: false,
};
Two decisions here I still like.
Nothing is saved by the import. The dashboard drops the result into the same editor the user would type into, and they have to press Save. An LLM rewriting someone's professional history should never be the last step. The person whose name is on it reads it first.
An import never erases anything. The client merges with fallbacks:
setDraft((d) => ({
...d,
headline: data.profile.headline || d.headline,
bio: data.profile.bio || d.bio,
skills: data.profile.skills.length ? data.profile.skills : d.skills,
lookingFor: data.profile.lookingFor || d.lookingFor,
minRate: data.profile.minRate ?? d.minRate,
importedAt: Date.now(),
}));
If the model returns an empty lookingFor because the profile did not say, the user's hand-written one survives. (Skills are the exception: a non-empty import replaces the whole list rather than merging. That is a choice I made without thinking about it, and I am not sure it is right.)
The schema is a suggestion. The clamp is the contract.
Structured outputs guarantee the response parses and has the right keys and types. They do not enforce "max 2500 chars" written inside a description. That text is a request to the model, not a constraint on the output. So after parsing, the route clamps everything itself:
profile: {
headline: String(out.headline || "").slice(0, 120),
bio: String(out.bio || "").slice(0, 4000),
skills: (Array.isArray(out.skills) ? out.skills : []).map((x) => String(x).slice(0, 60)).slice(0, 30),
lookingFor: String(out.lookingFor || "").slice(0, 2000),
minRate: Number.isFinite(Number(out.minRate)) && Number(out.minRate) > 0 ? Number(out.minRate) : null,
},
Then, when the user hits Save, PUT /api/me does not trust the import route either. It re-validates every field with its own str(p.bio, 4000), slice(0, 30) and so on. The import is just one way text ends up in the editor; the user could also have typed 9,000 characters by hand, and the save path has to handle both.
I think that layering is correct. What I got wrong is that each layer picked its own numbers.
Four limits for one field
Here is every limit I could find in the repo, per field:
| Field | Schema asks the model for | Import clamp | Save clamp | Matcher actually reads |
|---|---|---|---|---|
| headline | 120 chars | 120 | 120 | all |
| bio | 2500 chars | 4000 | 4000 | first 1500 |
| lookingFor | 600 chars | 2000 | 2000 | first 800 |
| skills | up to 20 | 30 | 30 | all |
The last column comes from the scoring prompt in lib/ai.ts:
About: ${profile.bio.slice(0, 1500) || "n/a"}
Looking for: ${profile.lookingFor.slice(0, 800) || "n/a"}
That slice was deliberate when I wrote it. The matcher runs once per job per user inside a cron, capped at 150 scores per user per day, and every character of profile is paid for on every call. Trimming the profile is a reasonable cost decision.
The problem is that nothing else in the product knows about it.
- The textarea has no
maxLengthand no counter. Its label says "the more, the better matching". - The save endpoint happily stores 4,000 characters.
- The import prompt asks the model to write up to 2,500 characters, built as "overview plus notable work history", in that order.
Put those together. A user imports their profile, the model writes a bio that opens with their overview and ends with their best client wins, and the wins sit after character 1,500. The user reads it, likes it, saves it. The scorer never sees the part they were proudest of. Nothing errors. The scores are just a bit worse than they could be, and nobody, including me, would ever notice from the outside.
Same story with lookingFor, which is arguably the more important field: it is where people write what to avoid ("no WordPress, no pure design"), and the system prompt tells the model to weigh it. The save path allows 2,000 characters. The model reads 800. If someone writes their positives first and their dealbreakers last, the dealbreakers are the part that gets cut.
Why this happens
None of these numbers is wrong on its own. 120 for a headline matches Upwork. 4,000 is a sane storage cap. 1,500 is a sane prompt budget. Each one was written in a different file, at a different time, for a different reason, by me.
The bug is that a length limit is product behaviour, and I treated it as a local implementation detail. There are really only two numbers that matter for the bio: how much I am willing to store, and how much the matcher will read. Everything the user sees should be derived from the second one, because that is the one that changes the result they get.
What I am going to change
None of this has shipped yet. This is the list:
-
One constants object.
PROFILE_LIMITS = { headline: 120, bio: 1500, lookingFor: 800, skills: 30 }inlib/models.ts, imported by the schema descriptions, both clamps, the textareas and the prompt builder. If I want the matcher to read more, I change one number and pay for it knowingly. - Store what is read. Saving 4,000 characters the scorer ignores is not generosity, it is a silent truncation with extra steps. Cap the save at what the matcher reads.
-
Show it. A character counter on both textareas, turning amber near the limit, so the user decides what goes in the first 1,500 rather than my
slicedeciding for them. - Ask the import to front-load. Change the bio description to put the strongest, most specific work first, and to stay inside the real limit. It is still only a request, which is why the clamp stays.
- Rate-limit the import route. It checks the session and truncates input to 24,000 characters, but there is no per-user cap. I flagged this in an earlier post and it is still open.
There is also a question I cannot answer from the code: how often a real Cmd+A of an Upwork profile goes past that 24,000-character input cut, and what falls off the end when it does. I have not measured it, so I am not going to guess. It goes on the list as "paste five real profiles and look".
The general version
If you hand LLM output to a pipeline, you probably have the same shape somewhere: a limit in the prompt, a limit in the parser, a limit in the database, and a limit where the text is finally used. Structured outputs make the first two feel solid because the JSON always parses. But "it parses" and "the downstream consumer sees all of it" are different properties, and only the second one is what the user is paying for.
My rule going forward: every text field that feeds a model has exactly one limit that matters, it lives in one place, and the UI shows it.
The matcher this feeds is described in Cheap Filters First, LLM Last, and the other half of the same prompt, the job post a stranger writes, is in A Stranger Writes Half of My AI Matcher's Prompt. The product itself is Upwork Scout.
Have you found a limit like this in your own LLM pipeline, one the user could not see but the model was quietly obeying? I would like to hear how you surfaced it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.